diff --git a/Dockerfile b/Dockerfile index 172029195c..ebf6a53ff8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,18 +9,27 @@ FROM node:lts-alpine3.12 # You could also use this to specify a particular version number. ARG PACKAGENAME=cloudsploit +# Create a non-root user and group +RUN addgroup -S cloudsploit && adduser -S cloudsploit -G cloudsploit + COPY . /var/scan/cloudsploit/ +# Set the working directory to /var/scan +WORKDIR /var/scan + # Install cloudsploit/scan into the container using npm from NPM -RUN cd /var/scan \ -&& npm init --yes \ +RUN npm init --yes \ && npm install ${PACKAGENAME} \ -&& npm link /var/scan/cloudsploit +&& npm link /var/scan/cloudsploit \ +&& chown -R cloudsploit:cloudsploit /var/scan # Setup the container's path so that you can run cloudsploit directly # in case someone wants to customize it when running the container. ENV PATH "$PATH:/var/scan/node_modules/.bin" +# Switch to non-root user +USER cloudsploit + # By default, run the scan. CMD allows consumers of the container to supply # command line arguments to the run command to control how this executes. # Thus, you can use the parameters that you would normally give to index.js diff --git a/collectors/aws/accessanalyzer/listFindingsV2.js b/collectors/aws/accessanalyzer/listFindingsV2.js new file mode 100644 index 0000000000..3f9240b56e --- /dev/null +++ b/collectors/aws/accessanalyzer/listFindingsV2.js @@ -0,0 +1,49 @@ +var AWS = require('aws-sdk'); +var async = require('async'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var accessanalyzer = new AWS.AccessAnalyzer(AWSConfig); + async.eachLimit(collection.accessanalyzer.listAnalyzers[AWSConfig.region].data, 15, function(analyzer, cb) { + collection.accessanalyzer.listFindingsV2[AWSConfig.region][analyzer.arn] = {}; + var params = { + analyzerArn: analyzer.arn + }; + + var paginating = false; + var paginateCb = function(err, data) { + if (err) collection.accessanalyzer.listFindingsV2[AWSConfig.region][analyzer.arn].err = err; + + if (!data) return cb(); + + if (paginating && data.findings && data.findings.length && + collection.accessanalyzer.listFindingsV2[AWSConfig.region][analyzer.arn].data.findings && + collection.accessanalyzer.listFindingsV2[AWSConfig.region][analyzer.arn].data.findings.length) { + collection.accessanalyzer.listFindingsV2[AWSConfig.region][analyzer.arn].data.findings = collection.accessanalyzer.listFindingsV2[AWSConfig.region][analyzer.arn].data.findings.concat(data.findings); + } else { + collection.accessanalyzer.listFindingsV2[AWSConfig.region][analyzer.arn].data = data; + } + + if (data.nextToken && data.nextToken.length) { + paginating = true; + return execute(data.nextToken); + } + + cb(); + }; + + function execute(nextToken) { // eslint-disable-line no-inner-declarations + var localParams = JSON.parse(JSON.stringify(params || {})); + if (nextToken) localParams['nextToken'] = nextToken; + if (nextToken) { + helpers.makeCustomCollectorCall(accessanalyzer, 'listFindingsV2', localParams, retries, null, null, null, paginateCb); + } else { + helpers.makeCustomCollectorCall(accessanalyzer, 'listFindingsV2', params, retries, null, null, null, paginateCb); + } + } + + execute(); + }, function(){ + callback(); + }); +}; \ No newline at end of file diff --git a/collectors/aws/apigateway/getClientCertificate.js b/collectors/aws/apigateway/getClientCertificate.js index 06f3c0b0d2..7c68bcfb22 100644 --- a/collectors/aws/apigateway/getClientCertificate.js +++ b/collectors/aws/apigateway/getClientCertificate.js @@ -26,7 +26,8 @@ module.exports = function(AWSConfig, collection, retries, callback) { collection.apigateway.getClientCertificate[AWSConfig.region][stage.clientCertificateId].err = err; return pCb(); } - collection.apigateway.getClientCertificate[AWSConfig.region][stage.clientCertificateId].data = data; + if (data) collection.apigateway.getClientCertificate[AWSConfig.region][stage.clientCertificateId].data = data; + pCb(); }); diff --git a/collectors/aws/apigateway/getIntegration.js b/collectors/aws/apigateway/getIntegration.js index dc7a737c45..9a4ce5ca39 100644 --- a/collectors/aws/apigateway/getIntegration.js +++ b/collectors/aws/apigateway/getIntegration.js @@ -35,7 +35,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { return mCb(); } - collection.apigateway.getIntegration[AWSConfig.region][api.id][resource.id][methodKey].data = data; + if (data) collection.apigateway.getIntegration[AWSConfig.region][api.id][resource.id][methodKey].data = data; mCb(); }); }, function(){ diff --git a/collectors/aws/appmesh/describeVirtualGateway.js b/collectors/aws/appmesh/describeVirtualGateway.js index 2bcb597458..e6a55fd82c 100644 --- a/collectors/aws/appmesh/describeVirtualGateway.js +++ b/collectors/aws/appmesh/describeVirtualGateway.js @@ -29,7 +29,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { collection.appmesh.describeVirtualGateway[AWSConfig.region][gateway.virtualGatewayName].err = err; } - collection.appmesh.describeVirtualGateway[AWSConfig.region][gateway.virtualGatewayName].data = data; + if (data) collection.appmesh.describeVirtualGateway[AWSConfig.region][gateway.virtualGatewayName].data = data; pCb(); }); diff --git a/collectors/aws/autoscaling/describeLaunchConfigurations.js b/collectors/aws/autoscaling/describeLaunchConfigurations.js index 55bb0adf0a..eb47515c0b 100644 --- a/collectors/aws/autoscaling/describeLaunchConfigurations.js +++ b/collectors/aws/autoscaling/describeLaunchConfigurations.js @@ -15,7 +15,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.autoscaling.describeLaunchConfigurations[AWSConfig.region][asg.AutoScalingGroupARN].err = err; } - collection.autoscaling.describeLaunchConfigurations[AWSConfig.region][asg.AutoScalingGroupARN].data = data; + if (data) collection.autoscaling.describeLaunchConfigurations[AWSConfig.region][asg.AutoScalingGroupARN].data = data; cb(); }); diff --git a/collectors/aws/cloudfront/getDistribution.js b/collectors/aws/cloudfront/getDistribution.js index 22df249208..f0f979c067 100644 --- a/collectors/aws/cloudfront/getDistribution.js +++ b/collectors/aws/cloudfront/getDistribution.js @@ -15,7 +15,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.cloudfront.getDistribution[AWSConfig.region][distribution.Id].err = err; } - collection.cloudfront.getDistribution[AWSConfig.region][distribution.Id].data = data; + if (data) collection.cloudfront.getDistribution[AWSConfig.region][distribution.Id].data = data; cb(); }); diff --git a/collectors/aws/cloudwatch/getEcMetricStatistics.js b/collectors/aws/cloudwatch/getEcMetricStatistics.js index 68146f8c9a..5ecb621e26 100644 --- a/collectors/aws/cloudwatch/getEcMetricStatistics.js +++ b/collectors/aws/cloudwatch/getEcMetricStatistics.js @@ -29,7 +29,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.cloudwatch.getEcMetricStatistics[AWSConfig.region][cluster.CacheClusterId].err = err; } - collection.cloudwatch.getEcMetricStatistics[AWSConfig.region][cluster.CacheClusterId].data = data; + if (data) collection.cloudwatch.getEcMetricStatistics[AWSConfig.region][cluster.CacheClusterId].data = data; cb(); }); diff --git a/collectors/aws/cloudwatch/getEsMetricStatistics.js b/collectors/aws/cloudwatch/getEsMetricStatistics.js index 8bcc532c13..559a3ee40a 100644 --- a/collectors/aws/cloudwatch/getEsMetricStatistics.js +++ b/collectors/aws/cloudwatch/getEsMetricStatistics.js @@ -29,7 +29,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.cloudwatch.getEsMetricStatistics[AWSConfig.region][domain.DomainName].err = err; } - collection.cloudwatch.getEsMetricStatistics[AWSConfig.region][domain.DomainName].data = data; + if (data) collection.cloudwatch.getEsMetricStatistics[AWSConfig.region][domain.DomainName].data = data; cb(); }); diff --git a/collectors/aws/cloudwatch/getRdsMetricStatistics.js b/collectors/aws/cloudwatch/getRdsMetricStatistics.js index 07754f026e..afc81333a7 100644 --- a/collectors/aws/cloudwatch/getRdsMetricStatistics.js +++ b/collectors/aws/cloudwatch/getRdsMetricStatistics.js @@ -29,7 +29,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.cloudwatch.getRdsMetricStatistics[AWSConfig.region][instance.DBInstanceIdentifier].err = err; } - collection.cloudwatch.getRdsMetricStatistics[AWSConfig.region][instance.DBInstanceIdentifier].data = data; + if (data) collection.cloudwatch.getRdsMetricStatistics[AWSConfig.region][instance.DBInstanceIdentifier].data = data; cb(); }); diff --git a/collectors/aws/cloudwatch/getRdsReadIOPSMetricStatistics.js b/collectors/aws/cloudwatch/getRdsReadIOPSMetricStatistics.js index abbdc2ef25..a080770755 100644 --- a/collectors/aws/cloudwatch/getRdsReadIOPSMetricStatistics.js +++ b/collectors/aws/cloudwatch/getRdsReadIOPSMetricStatistics.js @@ -29,7 +29,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.cloudwatch.getRdsReadIOPSMetricStatistics[AWSConfig.region][instance.DBInstanceIdentifier].err = err; } - collection.cloudwatch.getRdsReadIOPSMetricStatistics[AWSConfig.region][instance.DBInstanceIdentifier].data = data; + if (data) collection.cloudwatch.getRdsReadIOPSMetricStatistics[AWSConfig.region][instance.DBInstanceIdentifier].data = data; cb(); }); diff --git a/collectors/aws/cloudwatch/getRdsWriteIOPSMetricStatistics.js b/collectors/aws/cloudwatch/getRdsWriteIOPSMetricStatistics.js index dc06cb6861..5f68bfecd0 100644 --- a/collectors/aws/cloudwatch/getRdsWriteIOPSMetricStatistics.js +++ b/collectors/aws/cloudwatch/getRdsWriteIOPSMetricStatistics.js @@ -29,7 +29,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.cloudwatch.getRdsWriteIOPSMetricStatistics[AWSConfig.region][instance.DBInstanceIdentifier].err = err; } - collection.cloudwatch.getRdsWriteIOPSMetricStatistics[AWSConfig.region][instance.DBInstanceIdentifier].data = data; + if (data) collection.cloudwatch.getRdsWriteIOPSMetricStatistics[AWSConfig.region][instance.DBInstanceIdentifier].data = data; cb(); }); diff --git a/collectors/aws/cloudwatch/getredshiftMetricStatistics.js b/collectors/aws/cloudwatch/getredshiftMetricStatistics.js index 1504781a36..979f8543e6 100644 --- a/collectors/aws/cloudwatch/getredshiftMetricStatistics.js +++ b/collectors/aws/cloudwatch/getredshiftMetricStatistics.js @@ -29,7 +29,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.cloudwatch.getredshiftMetricStatistics[AWSConfig.region][cluster.ClusterIdentifier].err = err; } - collection.cloudwatch.getredshiftMetricStatistics[AWSConfig.region][cluster.ClusterIdentifier].data = data; + if (data) collection.cloudwatch.getredshiftMetricStatistics[AWSConfig.region][cluster.ClusterIdentifier].data = data; cb(); }); diff --git a/collectors/aws/codebuild/batchGetProjects.js b/collectors/aws/codebuild/batchGetProjects.js index f3b9e0b8d9..e176ce5d76 100644 --- a/collectors/aws/codebuild/batchGetProjects.js +++ b/collectors/aws/codebuild/batchGetProjects.js @@ -16,7 +16,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.codebuild.batchGetProjects[AWSConfig.region][project].err = err; } - collection.codebuild.batchGetProjects[AWSConfig.region][project].data = data; + if (data) collection.codebuild.batchGetProjects[AWSConfig.region][project].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/connect/instanceAttachmentStorageConfigs.js b/collectors/aws/connect/instanceAttachmentStorageConfigs.js index ef5b983ba2..3d147ab582 100644 --- a/collectors/aws/connect/instanceAttachmentStorageConfigs.js +++ b/collectors/aws/connect/instanceAttachmentStorageConfigs.js @@ -16,7 +16,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.connect.instanceAttachmentStorageConfigs[AWSConfig.region][instance.Id].err = err; } - collection.connect.instanceAttachmentStorageConfigs[AWSConfig.region][instance.Id].data = data; + if (data) collection.connect.instanceAttachmentStorageConfigs[AWSConfig.region][instance.Id].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/connect/listInstanceCallRecordingStorageConfigs.js b/collectors/aws/connect/listInstanceCallRecordingStorageConfigs.js index 6d24e6f137..11bd731010 100644 --- a/collectors/aws/connect/listInstanceCallRecordingStorageConfigs.js +++ b/collectors/aws/connect/listInstanceCallRecordingStorageConfigs.js @@ -16,7 +16,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.connect.listInstanceCallRecordingStorageConfigs[AWSConfig.region][instance.Id].err = err; } - collection.connect.listInstanceCallRecordingStorageConfigs[AWSConfig.region][instance.Id].data = data; + if (data) collection.connect.listInstanceCallRecordingStorageConfigs[AWSConfig.region][instance.Id].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/connect/listInstanceChatTranscriptStorageConfigs.js b/collectors/aws/connect/listInstanceChatTranscriptStorageConfigs.js index 163f67c79c..b65aafe668 100644 --- a/collectors/aws/connect/listInstanceChatTranscriptStorageConfigs.js +++ b/collectors/aws/connect/listInstanceChatTranscriptStorageConfigs.js @@ -16,7 +16,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.connect.listInstanceChatTranscriptStorageConfigs[AWSConfig.region][instance.Id].err = err; } - collection.connect.listInstanceChatTranscriptStorageConfigs[AWSConfig.region][instance.Id].data = data; + if (data) collection.connect.listInstanceChatTranscriptStorageConfigs[AWSConfig.region][instance.Id].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/connect/listInstanceExportedReportStorageConfigs.js b/collectors/aws/connect/listInstanceExportedReportStorageConfigs.js index 325536b259..851b7ddf9a 100644 --- a/collectors/aws/connect/listInstanceExportedReportStorageConfigs.js +++ b/collectors/aws/connect/listInstanceExportedReportStorageConfigs.js @@ -16,7 +16,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.connect.listInstanceExportedReportStorageConfigs[AWSConfig.region][instance.Id].err = err; } - collection.connect.listInstanceExportedReportStorageConfigs[AWSConfig.region][instance.Id].data = data; + if (data) collection.connect.listInstanceExportedReportStorageConfigs[AWSConfig.region][instance.Id].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/connect/listInstanceMediaStreamStorageConfigs.js b/collectors/aws/connect/listInstanceMediaStreamStorageConfigs.js index 0dd1323f52..4873a4ebe2 100644 --- a/collectors/aws/connect/listInstanceMediaStreamStorageConfigs.js +++ b/collectors/aws/connect/listInstanceMediaStreamStorageConfigs.js @@ -16,7 +16,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.connect.listInstanceMediaStreamStorageConfigs[AWSConfig.region][instance.Id].err = err; } - collection.connect.listInstanceMediaStreamStorageConfigs[AWSConfig.region][instance.Id].data = data; + if (data) collection.connect.listInstanceMediaStreamStorageConfigs[AWSConfig.region][instance.Id].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/dynamodb/describeContinuousBackups.js b/collectors/aws/dynamodb/describeContinuousBackups.js index eb919126df..d7884b6b22 100644 --- a/collectors/aws/dynamodb/describeContinuousBackups.js +++ b/collectors/aws/dynamodb/describeContinuousBackups.js @@ -15,7 +15,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.dynamodb.describeContinuousBackups[AWSConfig.region][table].err = err; } - collection.dynamodb.describeContinuousBackups[AWSConfig.region][table].data = data; + if (data) collection.dynamodb.describeContinuousBackups[AWSConfig.region][table].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/dynamodb/describeTable.js b/collectors/aws/dynamodb/describeTable.js index 1beeb3f4e4..c6ab50676c 100644 --- a/collectors/aws/dynamodb/describeTable.js +++ b/collectors/aws/dynamodb/describeTable.js @@ -15,7 +15,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.dynamodb.describeTable[AWSConfig.region][table].err = err; } - collection.dynamodb.describeTable[AWSConfig.region][table].data = data; + if (data) collection.dynamodb.describeTable[AWSConfig.region][table].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/dynamodb/listBackups.js b/collectors/aws/dynamodb/listBackups.js index 67bd0c374f..dd4138ed69 100644 --- a/collectors/aws/dynamodb/listBackups.js +++ b/collectors/aws/dynamodb/listBackups.js @@ -18,7 +18,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.dynamodb.listBackups[AWSConfig.region][table].err = err; } - collection.dynamodb.listBackups[AWSConfig.region][table].data = data; + if (data) collection.dynamodb.listBackups[AWSConfig.region][table].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/ec2/describeSnapshotAttribute.js b/collectors/aws/ec2/describeSnapshotAttribute.js index a7a3577a78..8439c61db1 100644 --- a/collectors/aws/ec2/describeSnapshotAttribute.js +++ b/collectors/aws/ec2/describeSnapshotAttribute.js @@ -16,7 +16,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.ec2.describeSnapshotAttribute[AWSConfig.region][snapshot.SnapshotId].err = err; } - collection.ec2.describeSnapshotAttribute[AWSConfig.region][snapshot.SnapshotId].data = data; + if (data) collection.ec2.describeSnapshotAttribute[AWSConfig.region][snapshot.SnapshotId].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/ec2/describeSnapshots.js b/collectors/aws/ec2/describeSnapshots.js index 854472d3ac..11a65e4862 100644 --- a/collectors/aws/ec2/describeSnapshots.js +++ b/collectors/aws/ec2/describeSnapshots.js @@ -9,6 +9,9 @@ module.exports = function(AWSConfig, collection, retries, callback) { var ec2 = new AWS.EC2(AWSConfig); var sts = new AWS.STS(AWSConfig); var paginating = false; + var maxSnapshots = 30000; // Limit the collection to 30,000 snapshots + var createdTime = new Date(); + createdTime.setDate(createdTime.getDate() - 30); helpers.makeCustomCollectorCall(sts, 'getCallerIdentity', {}, retries, null, null, null, function(stsErr, stsData) { if (stsErr || !stsData.Account) { @@ -38,16 +41,22 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.ec2.describeSnapshots[AWSConfig.region].err = err; } else if (data) { - if (paginating && data.Snapshots && data.Snapshots.length && + const filteredSnapshots = data.Snapshots? data.Snapshots.filter(snapshot => { + return new Date(snapshot.StartTime) > createdTime; + }) : []; + + if (paginating && filteredSnapshots && filteredSnapshots.length && collection.ec2.describeSnapshots[AWSConfig.region].data && - collection.ec2.describeSnapshots[AWSConfig.region].data.length) { - collection.ec2.describeSnapshots[AWSConfig.region].data = collection.ec2.describeSnapshots[AWSConfig.region].data.concat(data.Snapshots); + collection.ec2.describeSnapshots[AWSConfig.region].data.length && + collection.ec2.describeSnapshots[AWSConfig.region].data.length < maxSnapshots) { + collection.ec2.describeSnapshots[AWSConfig.region].data = collection.ec2.describeSnapshots[AWSConfig.region].data.concat(filteredSnapshots); } else if (!paginating) { - collection.ec2.describeSnapshots[AWSConfig.region].data = data.Snapshots; + collection.ec2.describeSnapshots[AWSConfig.region].data = filteredSnapshots; } - if (data.NextToken && + if (data.NextToken && data.NextToken.length && collection.ec2.describeSnapshots[AWSConfig.region].data && - collection.ec2.describeSnapshots[AWSConfig.region].data.length) { + collection.ec2.describeSnapshots[AWSConfig.region].data.length && + collection.ec2.describeSnapshots[AWSConfig.region].data.length < maxSnapshots) { paginating = true; return execute(data.NextToken); } diff --git a/collectors/aws/ec2/describeSubnets.js b/collectors/aws/ec2/describeSubnets.js index 766ba6e0a1..0ecc6857c3 100644 --- a/collectors/aws/ec2/describeSubnets.js +++ b/collectors/aws/ec2/describeSubnets.js @@ -25,7 +25,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { collection.ec2.describeSubnets[AWSConfig.region][vpc.VpcId].err = err; } - collection.ec2.describeSubnets[AWSConfig.region][vpc.VpcId].data = data; + if (data) collection.ec2.describeSubnets[AWSConfig.region][vpc.VpcId].data = data; cb(); }); diff --git a/collectors/aws/ecs/describeCluster.js b/collectors/aws/ecs/describeCluster.js index 965206dbe3..a539683097 100644 --- a/collectors/aws/ecs/describeCluster.js +++ b/collectors/aws/ecs/describeCluster.js @@ -19,7 +19,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { collection.ecs.describeCluster[AWSConfig.region][cluster].err = err; } - collection.ecs.describeCluster[AWSConfig.region][cluster].data = data; + if (data) collection.ecs.describeCluster[AWSConfig.region][cluster].data = data; cb(); }); diff --git a/collectors/aws/ecs/describeContainerInstances.js b/collectors/aws/ecs/describeContainerInstances.js index 8d0bdd4bdf..a03285a20a 100644 --- a/collectors/aws/ecs/describeContainerInstances.js +++ b/collectors/aws/ecs/describeContainerInstances.js @@ -22,7 +22,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { collection.ecs.describeContainerInstances[AWSConfig.region][containerInstance].err = err; } - collection.ecs.describeContainerInstances[AWSConfig.region][containerInstance].data = data; + if (data) collection.ecs.describeContainerInstances[AWSConfig.region][containerInstance].data = data; ccb(); }); diff --git a/collectors/aws/ecs/describeServices.js b/collectors/aws/ecs/describeServices.js index 08773ec42b..87fa1d7034 100644 --- a/collectors/aws/ecs/describeServices.js +++ b/collectors/aws/ecs/describeServices.js @@ -22,7 +22,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { collection.ecs.describeServices[AWSConfig.region][service].err = err; } - collection.ecs.describeServices[AWSConfig.region][service].data = data; + if (data) collection.ecs.describeServices[AWSConfig.region][service].data = data; ccb(); }); diff --git a/collectors/aws/ecs/describeTasks.js b/collectors/aws/ecs/describeTasks.js index cbe9e8e3e3..fad0738b3a 100644 --- a/collectors/aws/ecs/describeTasks.js +++ b/collectors/aws/ecs/describeTasks.js @@ -22,7 +22,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { collection.ecs.describeTasks[AWSConfig.region][task].err = err; } - collection.ecs.describeTasks[AWSConfig.region][task].data = data; + if (data) collection.ecs.describeTasks[AWSConfig.region][task].data = data; ccb(); }); diff --git a/collectors/aws/ecs/listTasks.js b/collectors/aws/ecs/listTasks.js index aee40fe258..082cad3ec9 100644 --- a/collectors/aws/ecs/listTasks.js +++ b/collectors/aws/ecs/listTasks.js @@ -18,10 +18,9 @@ module.exports = function(AWSConfig, collection, retries, callback) { helpers.makeCustomCollectorCall(ecs, 'listTasks', params, retries, null, null, null, function(err, data) { if (err) { collection.ecs.listTasks[AWSConfig.region][cluster].err = err; + } else if (data && data.taskArns) { + collection.ecs.listTasks[AWSConfig.region][cluster].data = data.taskArns; } - - collection.ecs.listTasks[AWSConfig.region][cluster].data = data.taskArns; - cb(); }); }, function(){ diff --git a/collectors/aws/elasticache/describeCacheSubnetGroups.js b/collectors/aws/elasticache/describeCacheSubnetGroups.js index c9439edbac..41736b4956 100644 --- a/collectors/aws/elasticache/describeCacheSubnetGroups.js +++ b/collectors/aws/elasticache/describeCacheSubnetGroups.js @@ -15,7 +15,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.elasticache.describeCacheSubnetGroups[AWSConfig.region][cluster.CacheSubnetGroupName].err = err; } - collection.elasticache.describeCacheSubnetGroups[AWSConfig.region][cluster.CacheSubnetGroupName].data = data; + if (data) collection.elasticache.describeCacheSubnetGroups[AWSConfig.region][cluster.CacheSubnetGroupName].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/elb/describeInstanceHealth.js b/collectors/aws/elb/describeInstanceHealth.js index 862c3a6ed3..d7bfa57ebd 100644 --- a/collectors/aws/elb/describeInstanceHealth.js +++ b/collectors/aws/elb/describeInstanceHealth.js @@ -15,7 +15,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.elb.describeInstanceHealth[AWSConfig.region][lb.DNSName].err = err; } - collection.elb.describeInstanceHealth[AWSConfig.region][lb.DNSName].data = data; + if (data) collection.elb.describeInstanceHealth[AWSConfig.region][lb.DNSName].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/elb/describeLoadBalancerAttributes.js b/collectors/aws/elb/describeLoadBalancerAttributes.js index 480267a287..282ee9ef2b 100644 --- a/collectors/aws/elb/describeLoadBalancerAttributes.js +++ b/collectors/aws/elb/describeLoadBalancerAttributes.js @@ -15,7 +15,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.elb.describeLoadBalancerAttributes[AWSConfig.region][lb.DNSName].err = err; } - collection.elb.describeLoadBalancerAttributes[AWSConfig.region][lb.DNSName].data = data; + if (data) collection.elb.describeLoadBalancerAttributes[AWSConfig.region][lb.DNSName].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/elb/describeLoadBalancerPolicies.js b/collectors/aws/elb/describeLoadBalancerPolicies.js index 8540fe804e..e0c70dc934 100644 --- a/collectors/aws/elb/describeLoadBalancerPolicies.js +++ b/collectors/aws/elb/describeLoadBalancerPolicies.js @@ -50,7 +50,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { helpers.makeCustomCollectorCall(elb, 'describeLoadBalancerPolicies', params, retries, null, null, null, function(err, data) { if (err) { collection.elb.describeLoadBalancerPolicies[AWSConfig.region][policy.DNSName].err = err; - } else { + } else if (data) { collection.elb.describeLoadBalancerPolicies[AWSConfig.region][policy.DNSName].data = data; } diff --git a/collectors/aws/elb/describeTags.js b/collectors/aws/elb/describeTags.js index a26db66f81..d673d44d4e 100644 --- a/collectors/aws/elb/describeTags.js +++ b/collectors/aws/elb/describeTags.js @@ -16,7 +16,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { collection.elb.describeTags[AWSConfig.region][lb.LoadBalancerName].err = err; } - collection.elb.describeTags[AWSConfig.region][lb.LoadBalancerName].data = data; + if (data) collection.elb.describeTags[AWSConfig.region][lb.LoadBalancerName].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/elbv2/describeListeners.js b/collectors/aws/elbv2/describeListeners.js index 7ab5d6f0b9..794a1fd53c 100644 --- a/collectors/aws/elbv2/describeListeners.js +++ b/collectors/aws/elbv2/describeListeners.js @@ -15,7 +15,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.elbv2.describeListeners[AWSConfig.region][lb.DNSName].err = err; } - collection.elbv2.describeListeners[AWSConfig.region][lb.DNSName].data = data; + if (data) collection.elbv2.describeListeners[AWSConfig.region][lb.DNSName].data = data; cb(); }); diff --git a/collectors/aws/elbv2/describeLoadBalancerAttributes.js b/collectors/aws/elbv2/describeLoadBalancerAttributes.js index 330020ff88..0e36f521a1 100644 --- a/collectors/aws/elbv2/describeLoadBalancerAttributes.js +++ b/collectors/aws/elbv2/describeLoadBalancerAttributes.js @@ -15,7 +15,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.elbv2.describeLoadBalancerAttributes[AWSConfig.region][lb.DNSName].err = err; } - collection.elbv2.describeLoadBalancerAttributes[AWSConfig.region][lb.DNSName].data = data; + if (data) collection.elbv2.describeLoadBalancerAttributes[AWSConfig.region][lb.DNSName].data = data; cb(); }); diff --git a/collectors/aws/elbv2/describeTags.js b/collectors/aws/elbv2/describeTags.js index b15b568f6a..cb41af06d0 100644 --- a/collectors/aws/elbv2/describeTags.js +++ b/collectors/aws/elbv2/describeTags.js @@ -15,8 +15,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.elbv2.describeTags[AWSConfig.region][lb.DNSName].err = err; } - - collection.elbv2.describeTags[AWSConfig.region][lb.DNSName].data = data; + if (data) collection.elbv2.describeTags[AWSConfig.region][lb.DNSName].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/elbv2/describeTargetGroups.js b/collectors/aws/elbv2/describeTargetGroups.js index 2194a937cb..410b9a9ff7 100644 --- a/collectors/aws/elbv2/describeTargetGroups.js +++ b/collectors/aws/elbv2/describeTargetGroups.js @@ -15,7 +15,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.elbv2.describeTargetGroups[AWSConfig.region][lb.DNSName].err = err; } - collection.elbv2.describeTargetGroups[AWSConfig.region][lb.DNSName].data = data; + if (data) collection.elbv2.describeTargetGroups[AWSConfig.region][lb.DNSName].data = data; cb(); }); diff --git a/collectors/aws/emr/describeSecurityConfiguration.js b/collectors/aws/emr/describeSecurityConfiguration.js index c3b8760520..f919eb8da8 100644 --- a/collectors/aws/emr/describeSecurityConfiguration.js +++ b/collectors/aws/emr/describeSecurityConfiguration.js @@ -26,7 +26,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.emr.describeSecurityConfiguration[AWSConfig.region][securityConfigurationName].err = err; } - collection.emr.describeSecurityConfiguration[AWSConfig.region][securityConfigurationName].data = data; + if (data) collection.emr.describeSecurityConfiguration[AWSConfig.region][securityConfigurationName].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/firehose/describeDeliveryStream.js b/collectors/aws/firehose/describeDeliveryStream.js index 0ad2541dcd..abebc7e57f 100644 --- a/collectors/aws/firehose/describeDeliveryStream.js +++ b/collectors/aws/firehose/describeDeliveryStream.js @@ -16,7 +16,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.firehose.describeDeliveryStream[AWSConfig.region][deliverystream].err = err; } - collection.firehose.describeDeliveryStream[AWSConfig.region][deliverystream].data = data; + if (data) collection.firehose.describeDeliveryStream[AWSConfig.region][deliverystream].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/guardduty/describePublishingDestination.js b/collectors/aws/guardduty/describePublishingDestination.js index 9a4ee1cb55..897608ef16 100644 --- a/collectors/aws/guardduty/describePublishingDestination.js +++ b/collectors/aws/guardduty/describePublishingDestination.js @@ -31,7 +31,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { collection.guardduty.describePublishingDestination[AWSConfig.region][destination.DestinationId].err = err; } - collection.guardduty.describePublishingDestination[AWSConfig.region][destination.DestinationId].data = data; + if (data) collection.guardduty.describePublishingDestination[AWSConfig.region][destination.DestinationId].data = data; pCb(); }); diff --git a/collectors/aws/guardduty/getDetector.js b/collectors/aws/guardduty/getDetector.js index e19098d888..b04b7c3d71 100644 --- a/collectors/aws/guardduty/getDetector.js +++ b/collectors/aws/guardduty/getDetector.js @@ -14,7 +14,10 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.guardduty.getDetector[AWSConfig.region][detectorId].err = err; } - collection.guardduty.getDetector[AWSConfig.region][detectorId].data = data; + if (data) { + data.id = detectorId; + collection.guardduty.getDetector[AWSConfig.region][detectorId].data = data; + } cb(); }); }, function(){ diff --git a/collectors/aws/guardduty/getFindings.js b/collectors/aws/guardduty/getFindings.js index c8b1a2e1f7..26132a0fa7 100644 --- a/collectors/aws/guardduty/getFindings.js +++ b/collectors/aws/guardduty/getFindings.js @@ -26,7 +26,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.guardduty.getFindings[AWSConfig.region][detectorId].err = err; } - collection.guardduty.getFindings[AWSConfig.region][detectorId].data = data; + if (data) collection.guardduty.getFindings[AWSConfig.region][detectorId].data = data; dcb(); }); diff --git a/collectors/aws/guardduty/getMasterAccount.js b/collectors/aws/guardduty/getMasterAccount.js index e24b7ad158..2e399f38f0 100644 --- a/collectors/aws/guardduty/getMasterAccount.js +++ b/collectors/aws/guardduty/getMasterAccount.js @@ -14,7 +14,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.guardduty.getMasterAccount[AWSConfig.region][detectorId].err = err; } - collection.guardduty.getMasterAccount[AWSConfig.region][detectorId].data = data; + if (data) collection.guardduty.getMasterAccount[AWSConfig.region][detectorId].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/guardduty/listFindings.js b/collectors/aws/guardduty/listFindings.js index 48dd2183d0..7e2826f89f 100644 --- a/collectors/aws/guardduty/listFindings.js +++ b/collectors/aws/guardduty/listFindings.js @@ -21,7 +21,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.guardduty.listFindings[AWSConfig.region][detectorId].err = err; } - collection.guardduty.listFindings[AWSConfig.region][detectorId].data = data; + if (data) collection.guardduty.listFindings[AWSConfig.region][detectorId].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/guardduty/listPublishingDestinations.js b/collectors/aws/guardduty/listPublishingDestinations.js index 0b32d7466c..e6d7f7a3b7 100644 --- a/collectors/aws/guardduty/listPublishingDestinations.js +++ b/collectors/aws/guardduty/listPublishingDestinations.js @@ -14,7 +14,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.guardduty.listPublishingDestinations[AWSConfig.region][detectorId].err = err; } - collection.guardduty.listPublishingDestinations[AWSConfig.region][detectorId].data = data; + if (data) collection.guardduty.listPublishingDestinations[AWSConfig.region][detectorId].data = data; }); cb(); diff --git a/collectors/aws/iam/getPolicyVersion.js b/collectors/aws/iam/getPolicyVersion.js index 0238ee7b73..70bb12a38f 100644 --- a/collectors/aws/iam/getPolicyVersion.js +++ b/collectors/aws/iam/getPolicyVersion.js @@ -29,7 +29,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.iam.getPolicyVersion[AWSConfig.region][policy.Arn].err = err; } - collection.iam.getPolicyVersion[AWSConfig.region][policy.Arn].data = data; + if (data) collection.iam.getPolicyVersion[AWSConfig.region][policy.Arn].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/kinesis/describeStream.js b/collectors/aws/kinesis/describeStream.js index 81fc8c0e32..31bcb0b1a1 100644 --- a/collectors/aws/kinesis/describeStream.js +++ b/collectors/aws/kinesis/describeStream.js @@ -16,7 +16,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.kinesis.describeStream[AWSConfig.region][stream].err = err; } - collection.kinesis.describeStream[AWSConfig.region][stream].data = data; + if (data) collection.kinesis.describeStream[AWSConfig.region][stream].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/lookoutvision/describeModel.js b/collectors/aws/lookoutvision/describeModel.js index c22ac371f6..a3216bf2c2 100644 --- a/collectors/aws/lookoutvision/describeModel.js +++ b/collectors/aws/lookoutvision/describeModel.js @@ -31,7 +31,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { collection.lookoutvision.describeModel[AWSConfig.region][model.ModelArn].err = err; } - collection.lookoutvision.describeModel[AWSConfig.region][model.ModelArn].data = data; + if (data) collection.lookoutvision.describeModel[AWSConfig.region][model.ModelArn].data = data; pCb(); }); }, function() { diff --git a/collectors/aws/managedblockchain/getMember.js b/collectors/aws/managedblockchain/getMember.js index 743f1cad22..17b99ffe65 100644 --- a/collectors/aws/managedblockchain/getMember.js +++ b/collectors/aws/managedblockchain/getMember.js @@ -28,7 +28,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { collection.managedblockchain.getMember[AWSConfig.region][member.Id].err = err; } - collection.managedblockchain.getMember[AWSConfig.region][member.Id].data = data; + if (data) collection.managedblockchain.getMember[AWSConfig.region][member.Id].data = data; mcb(); }); }, function(){ diff --git a/collectors/aws/mwaa/getEnvironment.js b/collectors/aws/mwaa/getEnvironment.js index 6b9ad3f0eb..9d992a124b 100644 --- a/collectors/aws/mwaa/getEnvironment.js +++ b/collectors/aws/mwaa/getEnvironment.js @@ -16,7 +16,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.mwaa.getEnvironment[AWSConfig.region][env].err = err; } - collection.mwaa.getEnvironment[AWSConfig.region][env].data = data; + if (data) collection.mwaa.getEnvironment[AWSConfig.region][env].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/opensearchserverless/getEncryptionSecurityPolicy.js b/collectors/aws/opensearchserverless/getEncryptionSecurityPolicy.js index d370a344a0..8944a652cd 100644 --- a/collectors/aws/opensearchserverless/getEncryptionSecurityPolicy.js +++ b/collectors/aws/opensearchserverless/getEncryptionSecurityPolicy.js @@ -16,7 +16,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.opensearchserverless.getEncryptionSecurityPolicy[AWSConfig.region][policy.name].err = err; } - collection.opensearchserverless.getEncryptionSecurityPolicy[AWSConfig.region][policy.name].data = data; + if (data) collection.opensearchserverless.getEncryptionSecurityPolicy[AWSConfig.region][policy.name].data = data; cb(); }); diff --git a/collectors/aws/opensearchserverless/getNetworkSecurityPolicy.js b/collectors/aws/opensearchserverless/getNetworkSecurityPolicy.js index e7ffd7b538..cc87011fcf 100644 --- a/collectors/aws/opensearchserverless/getNetworkSecurityPolicy.js +++ b/collectors/aws/opensearchserverless/getNetworkSecurityPolicy.js @@ -16,7 +16,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { collection.opensearchserverless.getNetworkSecurityPolicy[AWSConfig.region][policy.name].err = err; } - collection.opensearchserverless.getNetworkSecurityPolicy[AWSConfig.region][policy.name].data = data; + if (data) collection.opensearchserverless.getNetworkSecurityPolicy[AWSConfig.region][policy.name].data = data; cb(); }); diff --git a/collectors/aws/opensearchserverless/listEncryptionSecurityPolicies.js b/collectors/aws/opensearchserverless/listEncryptionSecurityPolicies.js index 164092cc06..590dbc71dc 100644 --- a/collectors/aws/opensearchserverless/listEncryptionSecurityPolicies.js +++ b/collectors/aws/opensearchserverless/listEncryptionSecurityPolicies.js @@ -10,7 +10,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { helpers.makeCustomCollectorCall(opensearch, 'listSecurityPolicies', params, retries, null, null, null, function(err, data) { if (err) { collection.opensearchserverless.listEncryptionSecurityPolicies[AWSConfig.region].err = err; - } else { + } else if (data && data.securityPolicySummaries){ collection.opensearchserverless.listEncryptionSecurityPolicies[AWSConfig.region].data = data.securityPolicySummaries; } callback(); diff --git a/collectors/aws/opensearchserverless/listNetworkSecurityPolicies.js b/collectors/aws/opensearchserverless/listNetworkSecurityPolicies.js index 99e06b8b01..f1f55ca919 100644 --- a/collectors/aws/opensearchserverless/listNetworkSecurityPolicies.js +++ b/collectors/aws/opensearchserverless/listNetworkSecurityPolicies.js @@ -10,9 +10,9 @@ module.exports = function(AWSConfig, collection, retries, callback) { helpers.makeCustomCollectorCall(opensearch, 'listSecurityPolicies', params, retries, null, null, null, function(err, data) { if (err) { collection.opensearchserverless.listNetworkSecurityPolicies[AWSConfig.region].err = err; - } else { + } else if (data && data.securityPolicySummaries) { collection.opensearchserverless.listNetworkSecurityPolicies[AWSConfig.region].data = data.securityPolicySummaries; } callback(); }); -}; \ No newline at end of file +}; diff --git a/collectors/aws/s3control/getPublicAccessBlock.js b/collectors/aws/s3control/getPublicAccessBlock.js index 00d57ea3c9..0ea179f5f1 100644 --- a/collectors/aws/s3control/getPublicAccessBlock.js +++ b/collectors/aws/s3control/getPublicAccessBlock.js @@ -15,7 +15,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.s3control.getPublicAccessBlock[AWSConfig.region][accountId].err = err; } - collection.s3control.getPublicAccessBlock[AWSConfig.region][accountId].data = data; + if (data) collection.s3control.getPublicAccessBlock[AWSConfig.region][accountId].data = data; callback(); }); }; \ No newline at end of file diff --git a/collectors/aws/securityhub/getFindings.js b/collectors/aws/securityhub/getFindings.js new file mode 100644 index 0000000000..52e94a459d --- /dev/null +++ b/collectors/aws/securityhub/getFindings.js @@ -0,0 +1,40 @@ +var AWS = require('aws-sdk'); +var helpers = require(__dirname + '/../../../helpers/aws'); + +module.exports = function(AWSConfig, collection, retries, callback) { + var securityhub = new AWS.SecurityHub(AWSConfig); + collection.securityhub.getFindings[AWSConfig.region] = {}; + + const params = { + MaxResults: 100, + Filters: { + RecordState: [ + { + Comparison: 'EQUALS', + Value: 'ACTIVE' + } + ], + WorkflowStatus: [ + { + Comparison: 'EQUALS', + Value: 'NEW' + } + ] + } + }; + + var paginateCb = function(err, data) { + if (err) { + collection.securityhub.getFindings[AWSConfig.region].err = err; + } else if (data && data.Findings && data.Findings.length) { + collection.securityhub.getFindings[AWSConfig.region].data = [data.Findings]; // only returning the first finding + } + + callback(); + }; + + function execute() { + helpers.makeCustomCollectorCall(securityhub, 'getFindings', params, retries, null, null, null, paginateCb); + } + execute(); +}; diff --git a/collectors/aws/ses/getIdentityDkimAttributes.js b/collectors/aws/ses/getIdentityDkimAttributes.js index ee188d4273..3e51064970 100644 --- a/collectors/aws/ses/getIdentityDkimAttributes.js +++ b/collectors/aws/ses/getIdentityDkimAttributes.js @@ -27,10 +27,11 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.ses.getIdentityDkimAttributes[AWSConfig.region].err = err; } else if (data && data.DkimAttributes) { - allDkimAttributes = { - ...allDkimAttributes, - ...data.DkimAttributes - }; + var processedIdentities = Object.keys(data.DkimAttributes).map((key) => ({ + identityName: key, + ...data.DkimAttributes[key], + })); + allDkimAttributes = allDkimAttributes.concat(processedIdentities); } processIdentityChunk(chunkIndex + 1); }); diff --git a/collectors/aws/sqs/getQueueAttributes.js b/collectors/aws/sqs/getQueueAttributes.js index 9da6f17aeb..b85e355c25 100644 --- a/collectors/aws/sqs/getQueueAttributes.js +++ b/collectors/aws/sqs/getQueueAttributes.js @@ -19,7 +19,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.sqs.getQueueAttributes[AWSConfig.region][queue].err = err; } - collection.sqs.getQueueAttributes[AWSConfig.region][queue].data = data; + if (data) collection.sqs.getQueueAttributes[AWSConfig.region][queue].data = data; cb(); }); }, function(){ diff --git a/collectors/aws/support/describeTrustedAdvisorCheckResult.js b/collectors/aws/support/describeTrustedAdvisorCheckResult.js index 4f6cdeda97..76ed78cd1e 100644 --- a/collectors/aws/support/describeTrustedAdvisorCheckResult.js +++ b/collectors/aws/support/describeTrustedAdvisorCheckResult.js @@ -16,7 +16,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.support.describeTrustedAdvisorChecks[AWSConfig.region][check].err = err; } - collection.support.describeTrustedAdvisorChecks[AWSConfig.region][check].data = data; + if (data) collection.support.describeTrustedAdvisorChecks[AWSConfig.region][check].data = data; cb(); }); }, function() { diff --git a/collectors/aws/wafv2/getWebACL.js b/collectors/aws/wafv2/getWebACL.js index b8f5107b13..e2ed82a7c1 100644 --- a/collectors/aws/wafv2/getWebACL.js +++ b/collectors/aws/wafv2/getWebACL.js @@ -17,7 +17,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.wafv2.getWebACL[AWSConfig.region][acl.ARN].err = err; - } else { + } else if (data) { collection.wafv2.getWebACL[AWSConfig.region][acl.ARN].data = data; } cb(); diff --git a/collectors/aws/wafv2/getWebACLForCognitoUserPool.js b/collectors/aws/wafv2/getWebACLForCognitoUserPool.js index 5e0f685551..756032c4d8 100644 --- a/collectors/aws/wafv2/getWebACLForCognitoUserPool.js +++ b/collectors/aws/wafv2/getWebACLForCognitoUserPool.js @@ -24,7 +24,7 @@ module.exports = function(AWSConfig, collection, retries, callback) { if (err) { collection.wafv2.getWebACLForCognitoUserPool[AWSConfig.region][up.Id].err = err; } - collection.wafv2.getWebACLForCognitoUserPool[AWSConfig.region][up.Id].data = data; + if (data) collection.wafv2.getWebACLForCognitoUserPool[AWSConfig.region][up.Id].data = data; cb(); }); diff --git a/collectors/azure/blobService/listContainersSegmented.js b/collectors/azure/blobService/listContainersSegmented.js index d9579e61fc..ab65831785 100644 --- a/collectors/azure/blobService/listContainersSegmented.js +++ b/collectors/azure/blobService/listContainersSegmented.js @@ -1,58 +1,59 @@ +const { BlobServiceClient, StorageSharedKeyCredential } = require('@azure/storage-blob'); var async = require('async'); module.exports = function(collection, reliesOn, callback) { if (!reliesOn['storageAccounts.listKeys']) return callback(); - var azureStorage = require('azure-storage'); - if (!collection['blobService']['listContainersSegmented']) collection['blobService']['listContainersSegmented'] = {}; if (!collection['blobService']['getContainerAcl']) collection['blobService']['getContainerAcl'] = {}; - // Loop through regions and properties in reliesOn - async.eachOfLimit(reliesOn['storageAccounts.listKeys'], 10,function(regionObj, region, cb) { + async.eachOfLimit(reliesOn['storageAccounts.listKeys'], 10, function(regionObj, region, cb) { collection['blobService']['listContainersSegmented'][region] = {}; collection['blobService']['getContainerAcl'][region] = {}; async.eachOfLimit(regionObj, 10, function(subObj, resourceId, sCb) { collection['blobService']['listContainersSegmented'][region][resourceId] = {}; - if (subObj && subObj.data && subObj.data.keys && subObj.data.keys[0] && subObj.data.keys[0].value) { - // Extract storage account name from resourceId - var storageAccountName = resourceId.substring(resourceId.lastIndexOf('/') + 1); - var storageService = new azureStorage['BlobService'](storageAccountName, subObj.data.keys[0].value); - - storageService.listContainersSegmented(null, function(serviceErr, serviceResults) { - if (serviceErr || !serviceResults) { - collection['blobService']['listContainersSegmented'][region][resourceId].err = (serviceErr || 'No data returned'); - sCb(); - } else { - collection['blobService']['listContainersSegmented'][region][resourceId].data = serviceResults.entries; - - // Add ACLs - async.eachLimit(serviceResults.entries, 10, function(entryObj, entryCb) { - var entryId = `${resourceId}/blobService/${entryObj.name}`; - collection['blobService']['getContainerAcl'][region][entryId] = {}; - - storageService.getContainerAcl(entryObj.name, function(getErr, getData) { - if (getErr || !getData) { - collection['blobService']['getContainerAcl'][region][entryId].err = (getErr || 'No data returned'); - } else { - collection['blobService']['getContainerAcl'][region][entryId].data = getData; - } - entryCb(); - }); - }, function() { - sCb(); - }); + const key = subObj && subObj.data && subObj.data.keys && subObj.data.keys[0] && subObj.data.keys[0].value? subObj.data.keys[0].value : null; + if (!key) return sCb(); + + const storageAccountName = resourceId.substring(resourceId.lastIndexOf('/') + 1); + const credential = new StorageSharedKeyCredential(storageAccountName, key); + const blobServiceClient = new BlobServiceClient( + `https://${storageAccountName}.blob.core.windows.net`, + credential + ); + + const containers = []; + + (async() => { + try { + for await (const container of blobServiceClient.listContainers()) { + containers.push(container); } - }); - } else { - sCb(); - } - }, function() { - cb(); - }); - }, function() { - callback(); - }); + + collection['blobService']['listContainersSegmented'][region][resourceId].data = containers; + + // Get ACLs for each container + async.eachLimit(containers, 10, async(entryObj, entryCb) => { + const entryId = `${resourceId}/blobService/${entryObj.name}`; + collection['blobService']['getContainerAcl'][region][entryId] = {}; + + try { + const containerClient = blobServiceClient.getContainerClient(entryObj.name); + const aclResponse = await containerClient.getAccessPolicy(); + collection['blobService']['getContainerAcl'][region][entryId].data = aclResponse; + } catch (getErr) { + collection['blobService']['getContainerAcl'][region][entryId].err = getErr.message || getErr; + } + + entryCb(); + }, sCb); + } catch (serviceErr) { + collection['blobService']['listContainersSegmented'][region][resourceId].err = serviceErr.message || serviceErr; + sCb(); + } + })(); + }, cb); + }, callback); }; diff --git a/collectors/azure/queueService/listQueuesSegmented.js b/collectors/azure/queueService/listQueuesSegmented.js index a594798bdd..787986b014 100644 --- a/collectors/azure/queueService/listQueuesSegmented.js +++ b/collectors/azure/queueService/listQueuesSegmented.js @@ -1,58 +1,57 @@ +const { TableServiceClient, AzureNamedKeyCredential } = require('@azure/data-tables'); var async = require('async'); module.exports = function(collection, reliesOn, callback) { if (!reliesOn['storageAccounts.listKeys']) return callback(); - var azureStorage = require('azure-storage'); + if (!collection['tableService']['listTablesSegmented']) collection['tableService']['listTablesSegmented'] = {}; + if (!collection['tableService']['getTableAcl']) collection['tableService']['getTableAcl'] = {}; - if (!collection['queueService']['listQueuesSegmented']) collection['queueService']['listQueuesSegmented'] = {}; - if (!collection['queueService']['getQueueAcl']) collection['queueService']['getQueueAcl'] = {}; - - // Loop through regions and properties in reliesOn - async.eachOfLimit(reliesOn['storageAccounts.listKeys'], 10,function(regionObj, region, cb) { - collection['queueService']['listQueuesSegmented'][region] = {}; - collection['queueService']['getQueueAcl'][region] = {}; + async.eachOfLimit(reliesOn['storageAccounts.listKeys'], 10, function(regionObj, region, cb) { + collection['tableService']['listTablesSegmented'][region] = {}; + collection['tableService']['getTableAcl'][region] = {}; async.eachOfLimit(regionObj, 10, function(subObj, resourceId, sCb) { - collection['queueService']['listQueuesSegmented'][region][resourceId] = {}; - - if (subObj && subObj.data && subObj.data.keys && subObj.data.keys[0] && subObj.data.keys[0].value) { - // Extract storage account name from resourceId - var storageAccountName = resourceId.substring(resourceId.lastIndexOf('/') + 1); - var storageService = new azureStorage['QueueService'](storageAccountName, subObj.data.keys[0].value); - - storageService.listQueuesSegmented(null, function(serviceErr, serviceResults) { - if (serviceErr || !serviceResults) { - collection['queueService']['listQueuesSegmented'][region][resourceId].err = (serviceErr || 'No data returned'); - sCb(); - } else { - collection['queueService']['listQueuesSegmented'][region][resourceId].data = serviceResults.entries; - - // Add ACLs - async.eachLimit(serviceResults.entries, 10, function(entryObj, entryCb) { - var entryId = `${resourceId}/queueService/${entryObj.name}`; - collection['queueService']['getQueueAcl'][region][entryId] = {}; - - storageService.getQueueAcl(entryObj.name, function(getErr, getData) { - if (getErr || !getData) { - collection['queueService']['getQueueAcl'][region][entryId].err = (getErr || 'No data returned'); - } else { - collection['queueService']['getQueueAcl'][region][entryId].data = getData; - } - entryCb(); - }); - }, function() { - sCb(); - }); + collection['tableService']['listTablesSegmented'][region][resourceId] = {}; + + const key = subObj && subObj.data && subObj.data.keys && subObj.data.keys[0] && subObj.data.keys[0].value? subObj.data.keys[0].value:null; + if (!key) return sCb(); + + const storageAccountName = resourceId.substring(resourceId.lastIndexOf('/') + 1); + const credential = new AzureNamedKeyCredential(storageAccountName, key); + const serviceClient = new TableServiceClient( + `https://${storageAccountName}.table.core.windows.net`, + credential + ); + + const tables = []; + + (async() => { + try { + for await (const table of serviceClient.listTables()) { + tables.push(table.name); } - }); - } else { - sCb(); - } - }, function() { - cb(); - }); - }, function() { - callback(); - }); + + collection['tableService']['listTablesSegmented'][region][resourceId].data = tables; + + async.eachLimit(tables, 10, async(tableName, tableCb) => { + const tableId = `${resourceId}/tableService/${tableName}`; + collection['tableService']['getTableAcl'][region][tableId] = {}; + + try { + const aclResponse = await serviceClient.getAccessPolicy(tableName); + collection['tableService']['getTableAcl'][region][tableId].data = aclResponse; + } catch (getErr) { + collection['tableService']['getTableAcl'][region][tableId].err = getErr.message || getErr; + } + + tableCb(); + }, sCb); + } catch (tableErr) { + collection['tableService']['listTablesSegmented'][region][resourceId].err = tableErr.message || tableErr; + sCb(); + } + })(); + }, cb); + }, callback); }; diff --git a/collectors/azure/tableService/listTablesSegmented.js b/collectors/azure/tableService/listTablesSegmented.js index 0522c6a8a0..787986b014 100644 --- a/collectors/azure/tableService/listTablesSegmented.js +++ b/collectors/azure/tableService/listTablesSegmented.js @@ -1,58 +1,57 @@ +const { TableServiceClient, AzureNamedKeyCredential } = require('@azure/data-tables'); var async = require('async'); module.exports = function(collection, reliesOn, callback) { if (!reliesOn['storageAccounts.listKeys']) return callback(); - var azureStorage = require('azure-storage'); - if (!collection['tableService']['listTablesSegmented']) collection['tableService']['listTablesSegmented'] = {}; if (!collection['tableService']['getTableAcl']) collection['tableService']['getTableAcl'] = {}; - // Loop through regions and properties in reliesOn - async.eachOfLimit(reliesOn['storageAccounts.listKeys'], 10,function(regionObj, region, cb) { + async.eachOfLimit(reliesOn['storageAccounts.listKeys'], 10, function(regionObj, region, cb) { collection['tableService']['listTablesSegmented'][region] = {}; collection['tableService']['getTableAcl'][region] = {}; async.eachOfLimit(regionObj, 10, function(subObj, resourceId, sCb) { collection['tableService']['listTablesSegmented'][region][resourceId] = {}; - if (subObj && subObj.data && subObj.data.keys && subObj.data.keys[0] && subObj.data.keys[0].value) { - // Extract storage account name from resourceId - var storageAccountName = resourceId.substring(resourceId.lastIndexOf('/') + 1); - var storageService = new azureStorage['TableService'](storageAccountName, subObj.data.keys[0].value); - - storageService.listTablesSegmented(null, function(tableErr, tableResults) { - if (tableErr || !tableResults) { - collection['tableService']['listTablesSegmented'][region][resourceId].err = (tableErr || 'No data returned'); - sCb(); - } else { - collection['tableService']['listTablesSegmented'][region][resourceId].data = tableResults.entries; - - // Add table ACLs - async.eachLimit(tableResults.entries, 10, function(tableName, tableCb){ - var tableId = `${resourceId}/tableService/${tableName}`; - collection['tableService']['getTableAcl'][region][tableId] = {}; - - storageService.getTableAcl(tableName, function(getErr, getData){ - if (getErr || !getData) { - collection['tableService']['getTableAcl'][region][tableId].err = (getErr || 'No data returned'); - } else { - collection['tableService']['getTableAcl'][region][tableId].data = getData; - } - tableCb(); - }); - }, function(){ - sCb(); - }); + const key = subObj && subObj.data && subObj.data.keys && subObj.data.keys[0] && subObj.data.keys[0].value? subObj.data.keys[0].value:null; + if (!key) return sCb(); + + const storageAccountName = resourceId.substring(resourceId.lastIndexOf('/') + 1); + const credential = new AzureNamedKeyCredential(storageAccountName, key); + const serviceClient = new TableServiceClient( + `https://${storageAccountName}.table.core.windows.net`, + credential + ); + + const tables = []; + + (async() => { + try { + for await (const table of serviceClient.listTables()) { + tables.push(table.name); } - }); - } else { - sCb(); - } - }, function() { - cb(); - }); - }, function() { - callback(); - }); + + collection['tableService']['listTablesSegmented'][region][resourceId].data = tables; + + async.eachLimit(tables, 10, async(tableName, tableCb) => { + const tableId = `${resourceId}/tableService/${tableName}`; + collection['tableService']['getTableAcl'][region][tableId] = {}; + + try { + const aclResponse = await serviceClient.getAccessPolicy(tableName); + collection['tableService']['getTableAcl'][region][tableId].data = aclResponse; + } catch (getErr) { + collection['tableService']['getTableAcl'][region][tableId].err = getErr.message || getErr; + } + + tableCb(); + }, sCb); + } catch (tableErr) { + collection['tableService']['listTablesSegmented'][region][resourceId].err = tableErr.message || tableErr; + sCb(); + } + })(); + }, cb); + }, callback); }; diff --git a/exports.js b/exports.js index ed0e07a616..261e4485ff 100644 --- a/exports.js +++ b/exports.js @@ -51,6 +51,7 @@ module.exports = { 'webTierIamRole' : require(__dirname + '/plugins/aws/autoscaling/webTierIamRole.js'), 'appTierIamRole' : require(__dirname + '/plugins/aws/autoscaling/appTierIamRole.js'), 'asgUnusedLaunchConfiguration' : require(__dirname + '/plugins/aws/autoscaling/asgUnusedLaunchConfiguration.js'), + 'asgTagPropagation' : require(__dirname + '/plugins/aws/autoscaling/asgTagPropagation.js'), 'workgroupEncrypted' : require(__dirname + '/plugins/aws/athena/workgroupEncrypted.js'), 'workgroupEnforceConfiguration' : require(__dirname + '/plugins/aws/athena/workgroupEnforceConfiguration.js'), @@ -187,6 +188,8 @@ module.exports = { 'publicAmi' : require(__dirname + '/plugins/aws/ec2/publicAmi.js'), 'encryptedAmi' : require(__dirname + '/plugins/aws/ec2/encryptedAmi.js'), 'amiHasTags' : require(__dirname + '/plugins/aws/ec2/amiHasTags.js'), + 'amiNamingConvention' : require(__dirname + '/plugins/aws/ec2/amiNamingConvention.js'), + 'oldAmi' : require(__dirname + '/plugins/aws/ec2/oldAmi.js'), 'instanceIamRole' : require(__dirname + '/plugins/aws/ec2/instanceIamRole.js'), 'ebsBackupEnabled' : require(__dirname + '/plugins/aws/ec2/ebsBackupEnabled.js'), 'ebsEncryptionEnabled' : require(__dirname + '/plugins/aws/ec2/ebsEncryptionEnabled.js'), @@ -243,6 +246,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'), @@ -268,6 +273,9 @@ module.exports = { 'eksSecurityGroups' : require(__dirname + '/plugins/aws/eks/eksSecurityGroups.js'), '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'), @@ -513,6 +521,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'), @@ -649,6 +659,8 @@ module.exports = { 'docdbClusterBackupRetention' : require(__dirname + '/plugins/aws/documentDB/docdbClusterBackupRetention.js'), 'docdbCertificateRotated' : require(__dirname + '/plugins/aws/documentDB/docdbCertificateRotated.js'), 'docdbClusterProfilerEnabled' : require(__dirname + '/plugins/aws/documentDB/docdbClusterProfilerEnabled.js'), + 'docdbEncryptionInTransit' : require(__dirname + '/plugins/aws/documentDB/docdbEncryptionInTransit.js'), + 'docdbAuditLoggingEnabled' : require(__dirname + '/plugins/aws/documentDB/docdbAuditLoggingEnabled.js'), 'instanceMediaStreamsEncrypted' : require(__dirname + '/plugins/aws/connect/instanceMediaStreamsEncrypted.js'), 'instanceTranscriptsEncrypted' : require(__dirname + '/plugins/aws/connect/instanceTranscriptsEncrypted.js'), @@ -695,6 +707,8 @@ module.exports = { 'ecsClustersHaveTags' : require(__dirname + '/plugins/aws/ecs/ecsClustersHaveTags.js'), 'ecsClusterWithActiveTask' : require(__dirname + '/plugins/aws/ecs/ecsClusterWithActiveTask.js'), 'ecsClusterActiveService' : require(__dirname + '/plugins/aws/ecs/ecsClusterActiveService.js'), + 'ecsServicePublicIpDisabled' : require(__dirname + '/plugins/aws/ecs/ecsServicePublicIpDisabled.js'), + 'ecsFargatePlatformVersion' : require(__dirname + '/plugins/aws/ecs/ecsFargatePlatformVersion.js'), 'cognitoHasWafEnabled' : require(__dirname + '/plugins/aws/cognito/cognitoHasWafEnabled.js'), 'cognitoMFAEnabled' : require(__dirname + '/plugins/aws/cognito/cognitoMFAEnabled.js'), @@ -723,6 +737,7 @@ module.exports = { 'queueServiceLoggingEnabled' : require(__dirname + '/plugins/azure/storageaccounts/queueServiceLoggingEnabled.js'), 'tableServiceLoggingEnabled' : require(__dirname + '/plugins/azure/storageaccounts/tableServiceLoggingEnabled.js'), 'blobServiceLoggingEnabled' : require(__dirname + '/plugins/azure/storageaccounts/blobServiceLoggingEnabled.js'), + 'storageAccountPublicNetworkAccess': require(__dirname + '/plugins/azure/storageaccounts/storageAccountPublicNetworkAccess.js'), 'blobContainersPrivateAccess' : require(__dirname + '/plugins/azure/blobservice/blobContainersPrivateAccess.js'), 'blobServiceImmutable' : require(__dirname + '/plugins/azure/blobservice/blobServiceImmutable.js'), @@ -802,6 +817,7 @@ module.exports = { 'vmDiskHasTags' : require(__dirname + '/plugins/azure/virtualmachines/vmDiskHasTags.js'), 'snapshotHasTags' : require(__dirname + '/plugins/azure/virtualmachines/snapshotHasTags.js'), 'unattachedDiskWithDefaultEncryption': require(__dirname + '/plugins/azure/virtualmachines/unattachedDiskWithDefaultEncryption.js'), + 'unAttachedDiskByokEncryptionEnabled': require(__dirname + '/plugins/azure/virtualmachines/unAttachedDiskByokEncryptionEnabled.js'), 'snapshotPublicAccessDisabled' : require(__dirname + '/plugins/azure/virtualmachines/snapshotPublicAccessDisabled.js'), 'snapshotByokEncryptionEnabled' : require(__dirname + '/plugins/azure/virtualmachines/snapshotByokEncryptionEnabled.js'), 'systemAssignedIdentityEnabled' : require(__dirname + '/plugins/azure/virtualmachines/systemAssignedIdentityEnabled.js'), @@ -815,6 +831,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'), @@ -885,6 +902,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'), @@ -1000,6 +1019,9 @@ 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'), + 'appServicePublicAccess' : require(__dirname + '/plugins/azure/appservice/appServicePublicAccess.js'), 'rbacEnabled' : require(__dirname + '/plugins/azure/kubernetesservice/rbacEnabled.js'), 'aksManagedIdentity' : require(__dirname + '/plugins/azure/kubernetesservice/aksManagedIdentity.js'), @@ -1011,6 +1033,8 @@ module.exports = { 'aksDiagnosticLogsEnabled' : require(__dirname + '/plugins/azure/kubernetesservice/aksDiagnosticLogsEnabled.js'), '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'), @@ -1025,14 +1049,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'), @@ -1061,7 +1085,9 @@ module.exports = { 'manageKeyAccessAndPermissions' : require(__dirname + '/plugins/azure/keyvaults/manageKeyAccessAndPermissions.js'), 'rsaCertificateKeySize' : require(__dirname + '/plugins/azure/keyvaults/rsaCertificateKeySize.js'), 'keyVaultSecretExpiry' : require(__dirname + '/plugins/azure/keyvaults/keyVaultSecretExpiry.js'), + 'keyVaultSecretExpiryNonRbac' : require(__dirname + '/plugins/azure/keyvaults/keyVaultSecretExpiryNonRbac.js'), 'keyVaultKeyExpiry' : require(__dirname + '/plugins/azure/keyvaults/keyVaultKeyExpiry.js'), + 'keyVaultKeyExpiryNonRbac' : require(__dirname + '/plugins/azure/keyvaults/keyVaultKeyExpiryNonRbac.js'), 'allowedCertificateKeyTypes' : require(__dirname + '/plugins/azure/keyvaults/allowedCertificateKeyTypes.js'), 'appTierCmkInUse' : require(__dirname + '/plugins/azure/keyvaults/appTierCmkInUse.js'), 'keyVaultInUse' : require(__dirname + '/plugins/azure/keyvaults/keyVaultInUse.js'), @@ -1070,6 +1096,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'), @@ -1454,7 +1481,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'), @@ -1562,7 +1589,8 @@ module.exports = { 'clusterEncryption' : require(__dirname + '/plugins/google/kubernetes/clusterEncryption.js'), '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'), @@ -1601,8 +1629,16 @@ 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'), + + 'cloudFunctionV2HttpsOnly' : require(__dirname + '/plugins/google/cloudfunctionsv2/cloudFunctionV2HttpsOnly.js'), + 'functionV2DefaultServiceAccount': require(__dirname + '/plugins/google/cloudfunctionsv2/functionV2DefaultServiceAccount.js'), + 'cloudFunctionV2IngressSettings': require(__dirname + '/plugins/google/cloudfunctionsv2/cloudFunctionV2IngressSettings.js'), + 'cloudFunctionV2LabelsAdded' : require(__dirname + '/plugins/google/cloudfunctionsv2/cloudFunctionV2LabelsAdded.js'), + 'cloudFunctionV2OldRuntime' : require(__dirname + '/plugins/google/cloudfunctionsv2/cloudFunctionV2OldRuntime.js'), + 'cloudFunctionV2VPCConnector' : require(__dirname + '/plugins/google/cloudfunctionsv2/cloudFunctionV2VPCConnector.js'), 'computeAllowedExternalIPs' : require(__dirname + '/plugins/google/cloudresourcemanager/computeAllowedExternalIPs.js'), 'disableAutomaticIAMGrants' : require(__dirname + '/plugins/google/cloudresourcemanager/disableAutomaticIAMGrants.js'), diff --git a/helpers/asl/asl-1.js b/helpers/asl/asl-1.js index 06bfed0541..67569135b3 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,16 +64,191 @@ 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 = (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 prefixChars = Math.floor(prefixLength / 4); + var cidrPrefix = expandedCidr.substring(0, prefixChars); + var ipPrefix = expandedIp.substring(0, prefixChars); + + var isInRange = ipPrefix === cidrPrefix; + + 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); @@ -80,6 +269,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; } @@ -88,23 +279,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({ @@ -140,194 +363,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) { - 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') { - var userRegex = RegExp(condition.value); - 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 == '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) { @@ -338,87 +379,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; } } @@ -427,7 +785,7 @@ var runValidation = function(obj, condition, inputResultsArr, nestedResultArr) { } let resultObj = { - status: result, + status: conditionResult, message: message.join(', ') }; @@ -436,92 +794,281 @@ 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' && 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; } }); @@ -559,13 +1106,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({ @@ -590,7 +1137,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); } } diff --git a/helpers/asl/asl-old.js b/helpers/asl/asl-old.js new file mode 100644 index 0000000000..835392bd09 --- /dev/null +++ b/helpers/asl/asl-old.js @@ -0,0 +1,937 @@ +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') { + 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']; + } + return [obj[localPath], path]; + } + if (obj[localPath] || typeof obj[localPath] === 'boolean') { + return parse(obj[localPath], path); + } else return ['not set']; + } else if (!Array.isArray(obj) && path && path.length) { + if (obj[path] || typeof obj[path] === 'boolean') return [obj[path]]; + else { + if (cloud==='aws' && path.startsWith('arn:aws')) { + const template_string = path; + const placeholders = template_string.match(/{([^{}]+)}/g); + let extracted_values = []; + if (placeholders) { + extracted_values = placeholders.map(placeholder => { + const key = placeholder.slice(1, -1); + if (key === 'value') return [obj][0]; + else return obj[key]; + }); + } + // Replace other variables + let converted_string = template_string + .replace(/\{region\}/g, region) + .replace(/\{cloudAccount\}/g, accountId) + .replace(/\{resourceId\}/g, resourceId); + placeholders.forEach((placeholder, index) => { + if (index === placeholders.length - 1) { + converted_string = converted_string.replace(placeholder, extracted_values.pop()); + } else { + converted_string = converted_string.replace(placeholder, extracted_values.shift()); + } + }); + path = converted_string; + return [path]; + } else return ['not set']; + } + } else if (Array.isArray(obj)) { + return [obj]; + } else { + return [obj]; + } +}; +var transform = function(val, transformation) { + if (transformation == 'DATE') { + return new Date(val); + } else if (transformation == 'INTEGER') { + return parseInt(val); + } else if (transformation == 'STRING') { + return val.toString(); + } else if (transformation == 'DAYSFROM') { + // Return the number of days between the date and now + var now = new Date(); + var then = new Date(val); + var timeDiff = then.getTime() - now.getTime(); + var diff = (Math.round(timeDiff / (1000 * 3600 * 24))); + return diff; + } else if (transformation == 'COUNT') { + return val.length; + } else if (transformation == 'EACH') { + return val; + } else if (transformation == 'TOLOWERCASE') { + return val.toLowerCase(); + } else { + return val; + } +}; + +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; + } + + 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 + }); + } else if (logical === 'AND') { + 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 (passingResults && passingResults.length) { + results.push({ + status: 0, + resource: resource, + message: passingResults.join(' and '), + region: region + }); + } else { + results.push({ + status: 2, + resource: resource, + message: failingResults.join(' and '), + region: region + }); + } + } +}; + +var validate = function(condition, conditionResult, inputResultsArr, message, property, parsed) { + if (Array.isArray(property)){ + property = property[property.length-1]; + } + + // Special case for AliasTarget properties + if (property && property.includes('AliasTarget') && parsed && typeof parsed === 'object') { + // Handle the AliasTarget object which has HostedZoneId, DNSName, and EvaluateTargetHealth + if (condition.property && condition.property.includes('AliasTarget')) { + // Extract the specific AliasTarget sub-property if specified + const aliasProperty = condition.property.split('.')[1]; // Get the part after AliasTarget. + if (aliasProperty && parsed.AliasTarget && parsed.AliasTarget[aliasProperty]) { + condition.parsed = parsed.AliasTarget[aliasProperty]; + } else if (!aliasProperty && parsed.AliasTarget) { + condition.parsed = parsed.AliasTarget; + } + } + } else 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') { + // 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}`); + return 0; + } else { + conditionResult = 2; + message.push(`${property}: count of ${condition.parsed} was not greater than: ${condition.value}`); + return 2; + } + } 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}`); + return 0; + } else { + conditionResult = 2; + message.push(`${property}: count of ${condition.parsed} was not less than: ${condition.value}`); + return 2; + } + } else if (condition.op == 'NE') { + if (condition.parsed !== condition.value) { + message.push(`${property}: ${condition.parsed} is not: ${condition.value}`); + return 0; + } else { + conditionResult = 2; + message.push(`${property}: ${condition.parsed} is: ${condition.value}`); + return 2; + } + } else if (condition.op == 'MATCHES') { + if (userRegex.test(condition.parsed)) { + message.push(`${property}: ${condition.parsed} matches the regex: ${condition.value}`); + return 0; + } else { + conditionResult = 2; + message.push(`${property}: ${condition.parsed} does not match the regex: ${condition.value}`); + return 2; + } + } else if (condition.op == 'NOTMATCHES') { + if (!userRegex.test(condition.parsed)) { + message.push(`${condition.property}: ${condition.parsed} does not match the regex: ${condition.value}`); + return 0; + } else { + conditionResult = 2; + message.push(`${condition.property}: ${condition.parsed} matches the regex : ${condition.value}`); + return 2; + } + } 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; + let message = []; + + // Extract the values for the conditions + if (condition.property) { + + let conditionResult = 0; + let property; + if (Array.isArray(condition.property)) { + if (condition.property.length === 1) { + property = condition.property[0]; + } else if (condition.property.length > 1) { + property = condition.property.slice(0); + } + } else { + property = condition.property; + } + + // Handle AliasTarget special cases + let isAliasTargetProperty = false; + if (typeof property === 'string' && property.includes('AliasTarget')) { + isAliasTargetProperty = true; + 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 { + condition.parsed = parse(obj, condition.property)[0]; + } + + if ((typeof condition.parsed !== 'boolean' && !condition.parsed) || condition.parsed === 'not set'){ + conditionResult = 2; + message.push(`${property}: not set to any value`); + + let resultObj = { + status: conditionResult, + message: message.join(', ') + }; + + inputResultsArr.push(resultObj); + return resultObj; + } + + if (property.includes('[*]') && !isAliasTargetProperty) { + 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); + } + }); + } 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(', ') + }; + } else { + resultObj = { + status: 2, + 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(', ') + }; + + inputResultsArr.push(resultObj); + return resultObj; + } + } else { + // Transform the property if required + conditionResult = validate(condition, conditionResult, inputResultsArr, message, property, obj); + if (conditionResult) result = conditionResult; + } + } + + if (!message.length) { + message = ['The resource matched all required conditions']; + } + + let resultObj = { + status: result, + message: message.join(', ') + }; + + inputResultsArr.push(resultObj); + return resultObj; +}; + +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('.'); + + // 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' && 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; + } + } + } + } + + // 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('[*]')) { + 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 && 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 && newData.length){ + newData.forEach(dataElm =>{ + if (newPath) condition.property = JSON.parse(JSON.stringify(newPath)); + condition.validated = runValidation(dataElm, condition, inputResultsArr); + // Use the Name property as resource if available (common in Route53) + parsedResource = dataElm.Name || 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 ]; + } + 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 + }); + }); + } + } + }); + + compositeResult(inputResultsArr, parsedResource, region, results, logical); +}; + +var asl = function(source, input, resourceMap, cloud, accountId, callback) { + if (!source || !input) return callback('No source or input provided'); + if (!input.apis || !input.apis[0]) return callback('No APIs provided for input'); + if (!input.conditions || !input.conditions.length) return callback('No conditions provided for input'); + let service = input.conditions[0].service; + var subService = (input.conditions[0].subservice) ? input.conditions[0].subservice : null; + let api = input.conditions[0].api; + let resourcePath; + if (resourceMap && + resourceMap[service] && + resourceMap[service][api]) { + resourcePath = resourceMap[service][api]; + } + + if (!source[service]) return callback(`Source data did not contain service: ${service}`); + if (subService && !source[service][subService]) return callback(`Source data did not contain service: ${service}:${subService}`); + if (subService && !source[service][subService][api]) return callback(`Source data did not contain API: ${api}`); + if (!subService && !source[service][api]) return callback(`Source data did not contain API: ${api}`); + + var results = []; + let data = subService ? source[service][subService][api] : source[service][api]; + + for (let region in data) { + let regionVal = data[region]; + if (typeof regionVal !== 'object') continue; + if (regionVal.err) { + results.push({ + status: 3, + message: regionVal.err.message || 'Error', + region: region + }); + } 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, cloud, accountId); + } else { + if (!Object.keys(regionVal).length || (regionVal.data && (!regionVal.data.length || !Object.keys(regionVal.data).length))) { + results.push({ + status: 0, + message: 'No resources found in this region', + region: region + }); + } else { + for (let resourceName in regionVal) { + let resourceObj = regionVal[resourceName]; + if (resourceObj.err || !resourceObj.data) { + results.push({ + status: 3, + resource: resourceName, + message: resourceObj.err.message || 'Error', + region: region + }); + } else { + if (resourceObj.data && resourceObj.data.length){ + resourceObj.data.forEach(function(regionData) { + var resourceName = parse(regionData, resourcePath, region, cloud, accountId)[0]; + runConditions(input, regionData, results, resourcePath, resourceName, region, cloud, accountId); + }); + } else { + runConditions(input, resourceObj.data, results, resourcePath, resourceName, region, cloud, accountId); + } + } + } + } + } + } + + callback(null, results, data); +}; + +module.exports = asl; diff --git a/helpers/aws/api.js b/helpers/aws/api.js index a0227ae405..8f0dc0758c 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', @@ -1563,23 +1658,7 @@ var calls = { getFindings: { property: 'Findings', paginate: 'NextToken', - params: { - MaxResults: 100, - Filters: { - RecordState: [ - { - Comparison: 'EQUALS', - Value: 'ACTIVE' - } - ], - WorkflowStatus: [ - { - Comparison: 'EQUALS', - Value: 'NEW' - } - ] - } - } + override: true } }, SageMaker: { @@ -1834,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: { @@ -1841,7 +1935,10 @@ var postcalls = [ reliesOnCall: 'listCertificates', filterKey: 'CertificateArn', filterValue: 'CertificateArn' - } + }, + sendIntegration: { + enabled: true + }, }, AccessAnalyzer: { listFindings: { @@ -1929,7 +2026,8 @@ var postcalls = [ reliesOnCall: 'listFlows', filterKey: 'flowName', filterValue: 'flowName' - } + }, + sendIntegration: serviceMap['AppFlow'] }, Athena: { getWorkGroup: { @@ -2118,7 +2216,8 @@ var postcalls = [ reliesOnCall: 'describeConfigRules', filterKey: 'ConfigRuleName', filterValue: 'ConfigRuleName' - } + }, + sendIntegration: serviceMap['ConfigService'] }, CodeStar: { describeProject: { @@ -2194,6 +2293,12 @@ var postcalls = [ filterKey: 'ResourceName', filterValue: 'DBClusterArn' }, + describeDBClusterParameters: { + reliesOnService: 'docdb', + reliesOnCall: 'describeDBClusters', + filterKey: 'DBClusterParameterGroupName', + filterValue: 'DBClusterParameterGroup' + }, sendIntegration: serviceMap['DocumentDB'] }, DynamoDB: { @@ -2243,7 +2348,8 @@ var postcalls = [ reliesOnCall: 'listDomainNames', filterKey: 'DomainName', filterValue: 'DomainName' - } + }, + sendIntegration: serviceMap['OpenSearch'][0] }, S3: { getBucketLogging: { @@ -2358,7 +2464,8 @@ var postcalls = [ reliesOnCall: 'listUserPools', filterKey: 'UserPoolId', filterValue: 'Id' - } + }, + sendIntegration: serviceMap['Cognito'] }, EC2: { describeSubnets: { @@ -2404,9 +2511,7 @@ var postcalls = [ filterKey: 'resourceArn', filterValue: 'repositoryArn' }, - sendIntegration: { - enabled: true - } + sendIntegration: serviceMap['ECR'] }, ECRPUBLIC: { describeRepositories: { @@ -2453,15 +2558,6 @@ var postcalls = [ }, sendIntegration: serviceMap['ElasticBeanstalk'] }, - ElasticTranscoder: { - listJobsByPipeline: { - reliesOnService: 'elastictranscoder', - reliesOnCall: 'listPipelines', - filterKey: 'PipelineId', - filterValue: 'Id' - }, - sendIntegration: serviceMap['Elastic Transcoder'] - }, ELB: { describeLoadBalancerPolicies: { reliesOnService: 'elb', @@ -2661,7 +2757,8 @@ var postcalls = [ reliesOnService: 'firehose', reliesOnCall: 'listDeliveryStreams', override: true - } + }, + sendIntegration: serviceMap['Firehose'], }, KMS: { describeKey: { @@ -2700,7 +2797,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', @@ -2782,15 +2879,6 @@ var postcalls = [ filterValue: 'botAliasId' } }, - QLDB: { - describeLedger: { - reliesOnService: 'qldb', - reliesOnCall: 'listLedgers', - filterKey: 'Name', - filterValue: 'Name' - }, - sendIntegration: serviceMap['QLDB'] - }, ManagedBlockchain: { listMembers: { reliesOnService: 'managedblockchain', @@ -2913,7 +3001,8 @@ var postcalls = [ reliesOnCall: 'listIdentities', override: true, rateLimit: 1000 - } + }, + sendIntegration: serviceMap['SES'] }, SNS: { getTopicAttributes: { @@ -2967,7 +3056,8 @@ var postcalls = [ reliesOnService: 'wafv2', reliesOnCall: 'listWebACLs', override: true - } + }, + sendIntegration: serviceMap['WAF'] }, GuardDuty: { getDetector: { @@ -2990,6 +3080,7 @@ var postcalls = [ reliesOnCall: 'listDetectors', override: true, }, + sendIntegration: serviceMap['Guard Duty'], }, }, { @@ -3146,7 +3237,8 @@ var postcalls = [ reliesOnService: 'opensearchserverless', reliesOnCall: 'listNetworkSecurityPolicies', override: true - } + }, + sendIntegration: serviceMap['OpenSearch'][1] } } ]; diff --git a/helpers/aws/api_multipart.js b/helpers/aws/api_multipart.js index 4590b8ffb0..b4576bf397 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', @@ -1444,6 +1430,12 @@ var postcalls = [ filterKey: 'ResourceName', filterValue: 'DBClusterArn' }, + describeDBClusterParameters: { + reliesOnService: 'docdb', + reliesOnCall: 'describeDBClusters', + filterKey: 'DBClusterParameterGroupName', + filterValue: 'DBClusterParameterGroup' + }, }, DynamoDB: { describeTable: { @@ -1666,14 +1658,6 @@ var postcalls = [ override: true } }, - ElasticTranscoder: { - listJobsByPipeline: { - reliesOnService: 'elastictranscoder', - reliesOnCall: 'listPipelines', - filterKey: 'PipelineId', - filterValue: 'Id' - } - }, ELB: { describeLoadBalancerPolicies: { reliesOnService: 'elb', @@ -2018,7 +2002,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 +2076,6 @@ var postcalls = [ filterValue: 'botId' } }, - QLDB: { - describeLedger: { - reliesOnService: 'qldb', - reliesOnCall: 'listLedgers', - filterKey: 'Name', - filterValue: 'Name' - } - }, ManagedBlockchain: { listMembers: { reliesOnService: 'managedblockchain', diff --git a/helpers/aws/functions.js b/helpers/aws/functions.js index 3b66ac6acf..4824e94f8e 100644 --- a/helpers/aws/functions.js +++ b/helpers/aws/functions.js @@ -32,7 +32,6 @@ function waitForCredentialReport(iam, callback, CREDENTIAL_DOWNLOAD_STARTED) { //return callback(CREDENTIAL_REPORT_ERROR); return callback('Error downloading report'); } - //CREDENTIAL_REPORT_DATA = reportData; //callback(null, CREDENTIAL_REPORT_DATA); callback(null, reportData); @@ -44,7 +43,7 @@ function addResult(results, status, message, region, resource, custom){ // Override unknown results for regions that are opt-in if (status == 3 && region && regions.optin.indexOf(region) > -1 && message && (message.indexOf('AWS was not able to validate the provided access credentials') > -1 || - message.indexOf('The security token included in the request is invalid') > -1)) { + message.indexOf('The security token included in the request is invalid') > -1)) { results.push({ status: 0, message: 'Region is not enabled', @@ -335,6 +334,9 @@ function crossAccountPrincipal(principal, accountId, fetchPrincipals, settings={ } function hasFederatedUserRole(policyDocument) { + if (!policyDocument || !Array.isArray(policyDocument)) { + return false; + } // true iff every statement refers to federated user access for (let statement of policyDocument) { if (statement.Action && @@ -785,10 +787,20 @@ function remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, return rCb(err); } else { if (openIpv6Range && !localIpV6Exists) { - remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ - 'inboundRule': '::1/128', - 'action': 'ADDED' - }); + if (settings.input && settings.input[ipv6InputKey]) { + const newIpv6CidrRange = settings.input[ipv6InputKey].split(','); + for (const cidr of newIpv6CidrRange) { + remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ + 'inboundRule': cidr, + 'action': 'ADDED' + }); + } + } else { + remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ + 'inboundRule': '::1/128', + 'action': 'ADDED' + }); + } } else if (openIpv6Range && localIpV6Exists) { remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ 'inboundRule': '::1/128', @@ -797,10 +809,20 @@ function remediateOpenPorts(putCall, pluginName, protocol, port, config, cache, } if (openIpRange && !localIpExists) { - remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ - 'inboundRule': '127.0.0.1/32', - 'action': 'ADDED' - }); + if (settings.input && settings.input[ipv4InputKey]) { + const newIpCidrRange = settings.input[ipv4InputKey].split(','); + for (const cidr of newIpCidrRange) { + remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ + 'inboundRule': cidr, + 'action': 'ADDED' + }); + } + } else { + remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ + 'inboundRule': '127.0.0.1/32', + 'action': 'ADDED' + }); + } } else if (openIpRange && localIpExists){ remediation_file['remediate']['actions'][pluginName][resource]['steps'].push({ 'inboundRule': '127.0.0.1/32', @@ -1126,8 +1148,10 @@ var checkTags = function(cache, resourceName, resourceList, region, results, set ['resourcegroupstaggingapi', 'getResources', region]); if (!allResources || allResources.err || !allResources.data) { - helpers.addResult(results, 3, - 'Unable to query all resources from group tagging api:' + helpers.addError(allResources), region); + resourceList.map(arn => { + helpers.addResult(results, 3, + 'Unable to query all resources from group tagging api:' + helpers.addError(allResources), region, arn); + }); return; } var awsOrGov = defaultPartition(settings); @@ -1148,7 +1172,7 @@ var checkTags = function(cache, resourceName, resourceList, region, results, set }); }; -function checkSecurityGroup(securityGroup, cache, region) { +function checkSecurityGroup(securityGroup, cache, region, checkENIs = true) { let allowsAllTraffic; for (var p in securityGroup.IpPermissions) { var permission = securityGroup.IpPermissions[p]; @@ -1170,112 +1194,426 @@ function checkSecurityGroup(securityGroup, cache, region) { } } - if (allowsAllTraffic) { + if (allowsAllTraffic && checkENIs) { return checkNetworkInterface(securityGroup.GroupId, securityGroup.GroupName, '', region, null, securityGroup, cache, true); } - return false; + return allowsAllTraffic; } -var checkNetworkExposure = function(cache, source, subnetId, securityGroups, region, results) { +var getAttachedELBs = function(cache, source, region, resourceId, lbField, lbAttribute) { + let elbs = []; + + // check classice ELBs + var describeLoadBalancers = helpers.addSource(cache, source, + ['elb', 'describeLoadBalancers', region]); + + if (describeLoadBalancers && !describeLoadBalancers.err && describeLoadBalancers.data && describeLoadBalancers.data.length) { + elbs = describeLoadBalancers.data.filter(lb => lb[lbField] && lb[lbField].some(instance => instance[lbAttribute] === resourceId)); + } + + // check ALBs/NLBs + + var describeLoadBalancersv2 = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancers', region]); + + if (describeLoadBalancersv2 && !describeLoadBalancersv2.err && describeLoadBalancersv2.data && describeLoadBalancersv2.data.length) { + describeLoadBalancersv2.data.forEach(function(lb) { + lb.targetGroups = []; + var describeTargetGroups = helpers.addSource(cache, source, + ['elbv2', 'describeTargetGroups', region, lb.DNSName]); + + if (describeTargetGroups && !describeTargetGroups.err && describeTargetGroups.data && describeTargetGroups.data.TargetGroups && describeTargetGroups.data.TargetGroups.length) { + describeTargetGroups.data.TargetGroups.forEach(function(tg) { + var describeTargetHealth = helpers.addSource(cache, source, + ['elbv2', 'describeTargetHealth', region, tg.TargetGroupArn]); + if (describeTargetHealth && !describeTargetHealth.err && describeTargetHealth.data + && describeTargetHealth.data.TargetHealthDescriptions && describeTargetHealth.data.TargetHealthDescriptions.length) { + describeTargetHealth.data.TargetHealthDescriptions.forEach(healthDescription => { + if (healthDescription.Target && healthDescription.Target.Id && + healthDescription.Target.Id === resourceId) { + lb.targetGroups.push({targetgroupName: tg.TargetGroupName, targetGroupArn: tg.TargetGroupArn}); + } + }); + } + }); + } + + if (lb.targetGroups && lb.targetGroups.length) { + let hasListener = false; + var describeListeners = helpers.addSource(cache, source, + ['elbv2', 'describeListeners', region, lb.DNSName]); + if (describeListeners && describeListeners.data && describeListeners.data.Listeners && describeListeners.data.Listeners.length) { + describeListeners.data.Listeners.forEach(listener => { + if (!hasListener) { + hasListener = listener.DefaultActions.some(action => + action.TargetGroupArn && lb.targetGroups.some(tg => tg.targetGroupArn === action.TargetGroupArn) + ); + } + + }); + } + if (hasListener) { + elbs.push(lb); + } + } + }); + } + + return elbs; +}; + +var checkNetworkExposure = function(cache, source, subnets, securityGroups, elbs, region, results, resource) { var internetExposed = ''; + var isSubnetPrivate = false; + + if (resource && resource.functionArn) { + // Check Function URL exposure + if (resource.functionUrlConfig && resource.functionUrlConfig.data) { + if (resource.functionUrlConfig.data.AuthType === 'NONE') { + internetExposed += 'public function URL'; + } else if (resource.functionUrlConfig.data.AuthType === 'AWS_IAM' && + resource.functionPolicy && resource.functionPolicy.data) { + let authConfig = resource.functionPolicy.data; + if (authConfig.Policy) { + let statements = normalizePolicyDocument(authConfig.Policy); + + if (statements) { + let hasDenyAll = false; + let hasPublicAllow = false; + let hasRestrictiveConditions = false; + + for (let statement of statements) { + // Check for explicit deny statements first + if (statement.Effect === 'Deny') { + // Check if there's a deny for all principals + if ((!statement.Condition || Object.keys(statement.Condition).length === 0) && + globalPrincipal(statement.Principal)) { + hasDenyAll = true; + break; + } - // Scenario 1: check if resource is in a private subnet - let subnetRouteTableMap, privateSubnets; - var describeSubnets = helpers.addSource(cache, source, - ['ec2', 'describeSubnets', region]); - var describeRouteTables = helpers.addSource(cache, {}, - ['ec2', 'describeRouteTables', region]); + // Check for deny with IP restrictions + if (statement.Condition && + (statement.Condition['NotIpAddress'] || + statement.Condition['IpAddress'])) { + hasRestrictiveConditions = true; + } + } else if (statement.Effect === 'Allow') { + // Skip if the statement doesn't include relevant Lambda actions + if (!statement.Action || + (!Array.isArray(statement.Action) ? + !statement.Action.includes('lambda:InvokeFunctionUrl') : + !statement.Action.some(action => + action === '*' || + action === 'lambda:*' || + action === 'lambda:InvokeFunctionUrl' + ))) { + continue; + } - if (!describeRouteTables || describeRouteTables.err || !describeRouteTables.data) { - helpers.addResult(results, 3, - 'Unable to query for route tables: ' + helpers.addError(describeRouteTables), region); - } else if (!describeSubnets || describeSubnets.err || !describeSubnets.data) { - helpers.addResult(results, 3, - 'Unable to query for subnets: ' + helpers.addError(describeSubnets), region); - } else if (describeSubnets.data.length && subnetId) { - subnetRouteTableMap = getSubnetRTMap(describeSubnets.data, describeRouteTables.data); - privateSubnets = getPrivateSubnets(subnetRouteTableMap, describeSubnets.data, describeRouteTables.data); - if (privateSubnets && privateSubnets.length && privateSubnets.find(subnet => subnet === subnetId)) { - return ''; + // Check for * principal with no conditions + if (globalPrincipal(statement.Principal)) { + if (!statement.Condition || Object.keys(statement.Condition).length === 0) { + hasPublicAllow = true; + } else { + // Check for common restrictive conditions + const restrictiveConditions = [ + 'aws:SourceIp', + 'aws:SourceVpc', + 'aws:SourceVpce', + 'aws:PrincipalOrgID', + 'aws:PrincipalArn', + 'aws:SourceAccount' + ]; + + const hasRestriction = restrictiveConditions.some(condition => + Object.keys(statement.Condition).some(key => + key.toLowerCase().includes(condition.toLowerCase()) + ) + ); + + if (hasRestriction) { + hasRestrictiveConditions = true; + } else if (statement.Condition['StringEquals'] && + statement.Condition['StringEquals']['lambda:FunctionUrlAuthType'] === 'NONE') { + hasPublicAllow = true; + } + } + } + } + } + + // Only mark as exposed if we have a public allow and no restrictions + if (hasPublicAllow && !hasDenyAll && !hasRestrictiveConditions) { + internetExposed += internetExposed.length ? + ', function URL with global IAM access' : + 'function URL with global IAM access'; + } + } + } + } + } + + // Check API Gateway exposure + let getRestApis = helpers.addSource(cache, source, + ['apigateway', 'getRestApis', region]); + + if (getRestApis && getRestApis.data) { + for (let api of getRestApis.data) { + if (!api.id || !api.name) continue; + + // Get stages to check if API is deployed + let getStages = helpers.addSource(cache, source, + ['apigateway', 'getStages', region, api.id]); + + // Only include if API has at least one stage deployed + if (!getStages || getStages.err || !getStages.data || !getStages.data.item || !getStages.data.item.length) continue; + + // Get integrations for this API + let getIntegration = helpers.addSource(cache, source, + ['apigateway', 'getIntegration', region, api.id]); + + if (!getIntegration || getIntegration.err || !Object.keys(getIntegration).length) continue; + + for (let apiResource of Object.values(getIntegration)) { + // Check if any integration points to this Lambda function + let lambdaIntegrations = Object.values(apiResource).filter(integration => { + return integration && integration.data && (integration.data.type === 'AWS' || integration.data.type === 'AWS_PROXY') && + integration.data.uri && + integration.data.uri.includes(resource.functionArn); + }); + + if (lambdaIntegrations.length) { + internetExposed += internetExposed.length ? `, API Gateway ${api.name}` : `API Gateway ${api.name}`; + } + } + } } - // If the subnet is not private we will check if security groups and Network ACLs allow internal traffic } - // Scenario 2: check if security group allows all traffic - var describeSecurityGroups = helpers.addSource(cache, source, - ['ec2', 'describeSecurityGroups', region]); + // Check public endpoint access for specific resources like EKS + if (resource && resource.resourcesVpcConfig && resource.resourcesVpcConfig.endpointPublicAccess) { + return 'public endpoint access'; + } + if (!resource.functionArn) { + // Scenario 1: check if resource is in a private subnet + let subnetRouteTableMap, privateSubnets; + var describeSubnets = helpers.addSource(cache, source, + ['ec2', 'describeSubnets', region]); + var describeRouteTables = helpers.addSource(cache, {}, + ['ec2', 'describeRouteTables', region]); + + if (!describeRouteTables || describeRouteTables.err || !describeRouteTables.data) { + helpers.addResult(results, 3, + 'Unable to query for route tables: ' + helpers.addError(describeRouteTables), region); + } else if (!describeSubnets || describeSubnets.err || !describeSubnets.data) { + helpers.addResult(results, 3, + 'Unable to query for subnets: ' + helpers.addError(describeSubnets), region); + } else if (describeSubnets.data.length && subnets.length) { + subnetRouteTableMap = getSubnetRTMap(describeSubnets.data, describeRouteTables.data); + privateSubnets = getPrivateSubnets(subnetRouteTableMap, describeSubnets.data, describeRouteTables.data); + if (privateSubnets && privateSubnets.length) { + isSubnetPrivate = !subnets.some(subnet => !privateSubnets.includes(subnet.id)); + } - if (!describeSecurityGroups || describeSecurityGroups.err || !describeSecurityGroups.data) { - helpers.addResult(results, 3, - 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); - } else if (describeSecurityGroups.data.length && securityGroups && securityGroups.length) { - let instanceSGs = describeSecurityGroups.data.filter(sg => securityGroups.find(isg => isg.GroupId === sg.GroupId)); - for (var group of instanceSGs) { - let exposedSG = checkSecurityGroup(group, cache, region); - if (!exposedSG) { + // if it's in a private subnet and has no ELBs attached then its not exposed + if (isSubnetPrivate && (!elbs || !elbs.length) && !resource.functionArn) { return ''; - } else { - internetExposed += exposedSG; } } } + // Scenario 2: check if security group allows all traffic + var describeSecurityGroups; + if (!isSubnetPrivate && !resource.functionArn) { + describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + if (!describeSecurityGroups || describeSecurityGroups.err || !describeSecurityGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for security groups: ' + helpers.addError(describeSecurityGroups), region); + } else if (describeSecurityGroups.data.length && securityGroups && securityGroups.length) { + let instanceSGs = describeSecurityGroups.data.filter(sg => securityGroups.find(isg => isg.GroupId === sg.GroupId)); + for (var group of instanceSGs) { + let exposedSG = checkSecurityGroup(group, cache, region); + if (exposedSG) { + internetExposed += internetExposed ? `, ${exposedSG}` : exposedSG; + } + } + } + // if security group allows all traffic we need to check NACLs + if (internetExposed.length && !resource.functionArn) { + let subnetIds = subnets.map(s => s.id); + // Scenario 3: check if Network ACLs associated with the resource allow all traffic + var describeNetworkAcls = helpers.addSource(cache, source, + ['ec2', 'describeNetworkAcls', region]); + + if (!describeNetworkAcls || describeNetworkAcls.err || !describeNetworkAcls.data) { + helpers.addResult(results, 3, + `Unable to query for Network ACLs: ${helpers.addError(describeNetworkAcls)}`, region); + } else if (describeNetworkAcls.data.length && subnetIds) { + let naclDeny = true; + for (let subnetId of subnetIds) { + let instanceACL = describeNetworkAcls.data.find(acl => acl.Associations.find(assoc => assoc.SubnetId === subnetId)); + if (instanceACL && instanceACL.Entries && instanceACL.Entries.length) { + const allowRules = instanceACL.Entries.filter(entry => + entry.Egress === false && + entry.RuleAction === 'allow' && + (entry.CidrBlock === '0.0.0.0/0' || entry.Ipv6CidrBlock === '::/0') + ); + + const denyIPv4 = instanceACL.Entries.find(entry => + entry.Egress === false && + entry.RuleAction === 'deny' && + entry.CidrBlock === '0.0.0.0/0' + ); + + const denyIPv6 = instanceACL.Entries.find(entry => + entry.Egress === false && + entry.RuleAction === 'deny' && + entry.Ipv6CidrBlock === '::/0' + ); + + let exposed = allowRules.some(allowRule => { + return !instanceACL.Entries.some(denyRule => { + return ( + denyRule.Egress === false && + denyRule.RuleAction === 'deny' && + ( + (allowRule.CidrBlock && denyRule.CidrBlock === allowRule.CidrBlock) || + (allowRule.Ipv6CidrBlock && denyRule.Ipv6CidrBlock === allowRule.Ipv6CidrBlock) + ) && + denyRule.Protocol === allowRule.Protocol && + ( + denyRule.PortRange ? + (allowRule.PortRange && + denyRule.PortRange.From === allowRule.PortRange.From && + denyRule.PortRange.To === allowRule.PortRange.To) : true + ) && + denyRule.RuleNumber < allowRule.RuleNumber + ); + }); + }); - // Scenario 3: check if Network ACLs associated with the resource allow all traffic - var describeNetworkAcls = helpers.addSource(cache, source, - ['ec2', 'describeNetworkAcls', region]); + // exposed - if NACL has an allow all rule + if (exposed && !resource.functionArn) { + internetExposed += `, nacl ${instanceACL.NetworkAclId}`; + } - if (!describeNetworkAcls || describeNetworkAcls.err || !describeNetworkAcls.data) { - helpers.addResult(results, 3, - `Unable to query for Network ACLs: ${helpers.addError(describeNetworkAcls)}`, region); - } else if (describeNetworkAcls.data.length && subnetId) { - let instanceACL = describeNetworkAcls.data.find(acl => acl.Associations.find(assoc => assoc.SubnetId === subnetId)); - if (instanceACL && instanceACL.Entries && instanceACL.Entries.length) { - - const allowRules = instanceACL.Entries.filter(entry => - entry.Egress === false && - entry.RuleAction === 'allow' && - (entry.CidrBlock === '0.0.0.0/0' || entry.Ipv6CidrBlock === '::/0') - ); - - - // Checking if there's a deny rule with lower rule number - let exposed = allowRules.some(allowRule => { - // Check if there's a deny with a lower rule number - return !instanceACL.Entries.some(denyRule => { - return ( - denyRule.Egress === false && - denyRule.RuleAction === 'deny' && - ( - (allowRule.CidrBlock && denyRule.CidrBlock === allowRule.CidrBlock) || - (allowRule.Ipv6CidrBlock && denyRule.Ipv6CidrBlock === allowRule.Ipv6CidrBlock) - ) && - denyRule.Protocol === allowRule.Protocol && - ( - denyRule.PortRange ? - (allowRule.PortRange && - denyRule.PortRange.From === allowRule.PortRange.From && - denyRule.PortRange.To === allowRule.PortRange.To) : true - ) && - denyRule.RuleNumber < allowRule.RuleNumber - ); - }); - }); - if (exposed) { - internetExposed += `> nacl ${instanceACL.NetworkAclId}`; - } else { - internetExposed = ''; + // not exposed - if NACL has a deny rule + if (exposed || !denyIPv4 || !denyIPv6) { + naclDeny = false; + } + } else { + naclDeny = false; + } + } + + // not exposed - if all NACLs have deny rules + if (naclDeny && !resource.functionArn) { + return ''; + } } } + } + + // if there are no explicit allow or deny rules, we look at ELBs + if (elbs && elbs.length) { + if (!describeSecurityGroups || !describeSecurityGroups.data) { + describeSecurityGroups = helpers.addSource(cache, source, + ['ec2', 'describeSecurityGroups', region]); + } + + elbs.forEach(lb => { + let isLBPublic = false; + if (lb.Scheme && lb.Scheme.toLowerCase() === 'internet-facing') { + if (lb.SecurityGroups && lb.SecurityGroups.length) { + if (describeSecurityGroups && + !describeSecurityGroups.err && describeSecurityGroups.data && describeSecurityGroups.data.length) { + let elbSGs = describeSecurityGroups.data.filter(sg => lb.SecurityGroups.includes(sg.GroupId)); + for (var elbSG of elbSGs) { + let exposedSG = checkSecurityGroup(elbSG, cache, region, false); + if (exposedSG) { + isLBPublic = true; + } + } + } + } + } + if (isLBPublic) { + internetExposed += internetExposed.length ? `, elb ${lb.LoadBalancerName}`: `elb ${lb.LoadBalancerName}`; + } + }); } return internetExposed; }; +let getLambdaTargetELBs = function(cache, source, region) { + let lambdaELBMap = {}; + + var describeLoadBalancersv2 = helpers.addSource(cache, source, + ['elbv2', 'describeLoadBalancers', region]); + + if (!describeLoadBalancersv2 || describeLoadBalancersv2.err || !describeLoadBalancersv2.data) { + return lambdaELBMap; + } + + describeLoadBalancersv2.data.forEach(lb => { + var describeTargetGroups = helpers.addSource(cache, source, + ['elbv2', 'describeTargetGroups', region, lb.DNSName]); + + if (!describeTargetGroups || describeTargetGroups.err || !describeTargetGroups.data || + !describeTargetGroups.data.TargetGroups) return; + + describeTargetGroups.data.TargetGroups.forEach(tg => { + var describeTargetHealth = helpers.addSource(cache, source, + ['elbv2', 'describeTargetHealth', region, tg.TargetGroupArn]); + + if (!describeTargetHealth || describeTargetHealth.err || !describeTargetHealth.data || + !describeTargetHealth.data.TargetHealthDescriptions) return; + + describeTargetHealth.data.TargetHealthDescriptions.forEach(target => { + if (target.Target && target.Target.Id && + target.Target.Id.startsWith('arn:aws:lambda')) { + if (!lambdaELBMap[target.Target.Id]) { + lambdaELBMap[target.Target.Id] = []; + } + lb.targetGroups = lb.targetGroups || []; + lb.targetGroups.push({ + targetGroupName: tg.TargetGroupName, + targetGroupArn: tg.TargetGroupArn, + targets: [target.Target] + }); + + // Check if there's an active listener for this target group + let hasListener = false; + var describeListeners = helpers.addSource(cache, source, + ['elbv2', 'describeListeners', region, lb.DNSName]); + + if (describeListeners && describeListeners.data && + describeListeners.data.Listeners) { + hasListener = describeListeners.data.Listeners.some(listener => + listener.DefaultActions.some(action => + action.TargetGroupArn === tg.TargetGroupArn + ) + ); + } + + if (hasListener) { + lambdaELBMap[target.Target.Id].push(lb); + } + } + }); + }); + }); + + return lambdaELBMap; +}; + module.exports = { addResult: addResult, findOpenPorts: findOpenPorts, @@ -1314,5 +1652,7 @@ module.exports = { processFieldSelectors: processFieldSelectors, checkNetworkInterface: checkNetworkInterface, checkNetworkExposure: checkNetworkExposure, + getAttachedELBs: getAttachedELBs, + getLambdaTargetELBs }; diff --git a/helpers/azure/api.js b/helpers/azure/api.js index 8b77b76ff1..2f71d06782 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', @@ -161,15 +179,105 @@ var serviceMap = { BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'SQL Databases', BridgeCollectionService: 'databases', DataIdentifier: 'data', }, - 'AI & ML': + 'AI & ML': { enabled: true, isSingleSource: true, InvAsset: 'account', InvService: 'openAI', - InvResourceCategory: 'ai&ml', InvResourceType: 'OpenAI Accounts', BridgeProvider: 'Azure', + InvResourceCategory: 'ai&ml', InvResourceType: 'OpenAI Accounts', BridgeProvider: 'Azure', BridgeServiceName: 'openAI', BridgePluginCategoryName: 'AI & ML', BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'accounts', 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 @@ -197,7 +305,7 @@ var calls = { }, storageAccounts: { list: { - url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Storage/storageAccounts?api-version=2019-06-01', + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Storage/storageAccounts?api-version=2023-05-01', rateLimit: 3000 } }, @@ -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,13 +398,19 @@ 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: { url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.ContainerService/managedClusters?api-version=2020-03-01' } }, + managedInstances: { + list: { + url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Sql/managedInstances?api-version=2021-11-01' + } + }, networkWatchers: { listAll: { url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Network/networkWatchers?api-version=2022-01-01' @@ -333,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: { @@ -343,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: { @@ -370,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: { @@ -402,7 +518,8 @@ var calls = { list: { url: 'https://graph.microsoft.com/v1.0/applications/', graph: true, - } + }, + sendIntegration: serviceMap['Entra ID'][1] }, automationAccounts: { list: { @@ -417,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: { @@ -427,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: { @@ -437,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: { @@ -457,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' @@ -475,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: { @@ -495,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: { @@ -513,7 +636,7 @@ var calls = { } }, - computeGalleries: { + computeGalleries: { list: { url: 'https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Compute/galleries?api-version=2022-08-03' } @@ -625,6 +748,13 @@ var postcalls = { url: 'https://management.azure.com/{id}/providers/Microsoft.Compute/availabilitySets?api-version=2020-12-01' } }, + resources: { + listByResourceGroup: { + reliesOnPath: 'resourceGroups.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/resources?api-version=2021-04-01' + } + }, advancedThreatProtection: { get: { reliesOnPath: 'databaseAccounts.list', @@ -714,7 +844,7 @@ var postcalls = { properties: ['id'], url: 'https://management.azure.com/{id}/certificates?api-version=2023-11-01' } - }, + }, flowLogs: { list: { reliesOnPath: 'networkWatchers.listAll', @@ -803,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: { @@ -849,6 +980,13 @@ var postcalls = { url: 'https://management.azure.com/{id}/encryptionProtector?api-version=2015-05-01-preview' }, }, + managedInstanceEncryptionProtectors: { + listByInstance: { + reliesOnPath: 'managedInstances.list', + properties: ['id'], + url: 'https://management.azure.com/{id}/encryptionProtector?api-version=2024-05-01-preview' + }, + }, webApps: { getAuthSettings: { reliesOnPath: 'webApps.list', @@ -872,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: { @@ -880,13 +1026,14 @@ var postcalls = { properties: ['id'], url: 'https://management.azure.com/{id}/authConfigs?api-version=2023-05-01', } - }, + }, endpoints: { listByProfile: { reliesOnPath: 'profiles.list', properties: ['id'], url: 'https://management.azure.com/{id}/endpoints?api-version=2019-04-15' - } + }, + sendIntegration: serviceMap['CDN Profiles'][1] }, customDomain: { listByFrontDoorProfiles: { @@ -957,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: { @@ -1112,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 = { diff --git a/helpers/azure/auth.js b/helpers/azure/auth.js index fb491d8dc5..9927941349 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,81 +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 (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); } - - console.log(`[ERROR] Unhandled error from Azure API: Body: ${JSON.stringify(body)}`); + 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}`); + } + } + 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) { @@ -199,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; diff --git a/helpers/azure/functions.js b/helpers/azure/functions.js index bef05d144d..3619339ea9 100644 --- a/helpers/azure/functions.js +++ b/helpers/azure/functions.js @@ -775,22 +775,86 @@ function checkSecurityGroup(securityGroups) { return {exposed: true}; } -function checkNetworkExposure(cache, source, networkInterfaces, securityGroups, region, results) { +function checkNetworkExposure(cache, source, networkInterfaces, securityGroups, location, results, attachedResources, resource) { let exposedPath = ''; - if (securityGroups && securityGroups.length) { - // Scenario 1: check if security group allow all inbound traffic - let exposedSG = checkSecurityGroup(securityGroups); - if (exposedSG && exposedSG.exposed) { - if (exposedSG.nsg) { - exposedPath += `nsg ${exposedSG.nsg}` + const isFunctionApp = resource && resource.kind && + resource.kind.toLowerCase().includes('functionapp'); + + if (!isFunctionApp) { + if (securityGroups && securityGroups.length) { + // Scenario 1: check if security group allow all inbound traffic + let exposedSG = checkSecurityGroup(securityGroups); + if (exposedSG && exposedSG.exposed) { + if (exposedSG.nsg) { + return `nsg ${exposedSG.nsg}` + } else { + return ''; + } } } + } - return exposedPath + + const { applicationGateways, lbNames, frontDoors } = attachedResources; + + if (lbNames && lbNames.length) { + const loadBalancers = shared.addSource(cache, source, + ['loadBalancers', 'listAll', location]); + + if (loadBalancers && !loadBalancers.err && loadBalancers.data && loadBalancers.data.length) { + let resourceLBs = loadBalancers.data.filter(lb => lbNames.includes(lb.id)); + if (resourceLBs && resourceLBs.length) { + for (let lb of resourceLBs) { + let isPublic = false; + if (lb.frontendIPConfigurations && lb.frontendIPConfigurations.length) { + isPublic = lb.frontendIPConfigurations.some(ipConfig => ipConfig.properties + && ipConfig.properties.publicIPAddress && ipConfig.properties.publicIPAddress.id); + if (isPublic && ((lb.inboundNatRules && lb.inboundNatRules.length) || (lb.loadBalancingRules && lb.loadBalancingRules.length))) { + exposedPath += exposedPath.length ? `, lb ${lb.name}` : `lb ${lb.name}`; + } + } + } + } + } } -} + + if (applicationGateways && applicationGateways.length) { + for (const ag of applicationGateways) { + if (ag.frontendIPConfigurations && ag.frontendIPConfigurations.some(config => config.publicIPAddress && config.publicIPAddress.id)) { + exposedPath += exposedPath.length ? `, ag ${ag.name}` : `ag ${ag.name}`; + } + } + } + + if (frontDoors && frontDoors.length) { + for (const fd of frontDoors) { + if (!fd.associatedWafPolicies || !fd.associatedWafPolicies.length) { + exposedPath += exposedPath.length ? `, fd ${fd.name}` : `fd ${fd.name}`; + continue; + } + + // Check WAF policies + let hasSecureWaf = false; + for (const policy of fd.associatedWafPolicies) { + if (policy.policySettings && + policy.policySettings.enabledState === 'Enabled' && + policy.policySettings.mode === 'Prevention') { + hasSecureWaf = true; + break; + } + } + + if (!hasSecureWaf) { + exposedPath += exposedPath.length ? `, fd ${fd.name}` : `fd ${fd.name}`; + } + } + } + + + return exposedPath; +} module.exports = { addResult: addResult, findOpenPorts: findOpenPorts, @@ -803,7 +867,8 @@ module.exports = { remediateOpenPorts: remediateOpenPorts, remediateOpenPortsHelper: remediateOpenPortsHelper, checkMicrosoftDefender: checkMicrosoftDefender, - checkFlexibleServerConfigs: checkFlexibleServerConfigs, - checkNetworkExposure: checkNetworkExposure, + checkFlexibleServerConfigs:checkFlexibleServerConfigs, + checkNetworkExposure: checkNetworkExposure }; + diff --git a/helpers/azure/locations.js b/helpers/azure/locations.js index c376e3ded7..9cd3dcff0f 100644 --- a/helpers/azure/locations.js +++ b/helpers/azure/locations.js @@ -136,5 +136,6 @@ module.exports = { batchAccounts: locations, machineLearning: locations, apiManagementService: locations, - synapse: locations + synapse: locations, + managedInstances: locations }; diff --git a/helpers/google/api.js b/helpers/google/api.js index e8d0d0cc34..31c467b610 100644 --- a/helpers/google/api.js +++ b/helpers/google/api.js @@ -190,6 +190,33 @@ var serviceMap = { BridgeArnIdentifier: '', BridgeIdTemplate: '{name}', BridgeResourceType: 'models', BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'gcp-AI & ML', BridgeCollectionService: 'gcp-vertexai', DataIdentifier: 'data', + }, + 'CloudBuild': + { + enabled: true, isSingleSource: true, InvAsset: 'trigger', InvService: 'CloudBuild', + InvResourceCategory: 'cloud_resources', InvResourceType: 'trigger', BridgeServiceName: 'cloudbuild', + BridgePluginCategoryName: 'gcp-CloudBuild', BridgeProvider: 'Google', BridgeCall: 'triggers', + BridgeArnIdentifier: '', BridgeIdTemplate: 'projects/{cloudAccount}/locations/{region}/triggers/{name}', BridgeResourceType: 'triggers', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'gcp-CloudBuild', + BridgeCollectionService: 'gcp-cloudbuild', DataIdentifier: 'data', + }, + 'Cloud Composer': + { + enabled: true, isSingleSource: true, InvAsset: 'environment', InvService: 'Cloud Composer', + InvResourceCategory: 'cloud_resources', InvResourceType: 'composer_environment', BridgeServiceName: 'composer', + BridgePluginCategoryName: 'gcp-Cloud Composer', BridgeProvider: 'Google', BridgeCall: 'environments', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'environments', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'gcp-Cloud Composer', + BridgeCollectionService: 'gcp-composer', DataIdentifier: 'data', + }, + 'Resource Manager': + { + enabled: true, isSingleSource: true, InvAsset: 'organization', InvService: 'Resource Manager', + InvResourceCategory: 'cloud_resources', InvResourceType: 'Organization', BridgeServiceName: 'organizations', + BridgePluginCategoryName: 'gcp-Resource Manager', BridgeProvider: 'Google', BridgeCall: 'list', + BridgeArnIdentifier: '', BridgeIdTemplate: '', BridgeResourceType: 'organizations', + BridgeResourceNameIdentifier: 'name', BridgeExecutionService: 'gcp-Resource Manager', + BridgeCollectionService: 'gcp-organizations', DataIdentifier: 'data', } }; var calls = { @@ -212,7 +239,8 @@ var calls = { pagination: true, paginationKey: 'pageToken', dataFilterKey: 'environments' - } + }, + sendIntegration: serviceMap['Cloud Composer'] }, repositories: { list: { @@ -224,6 +252,22 @@ var calls = { enabled: true } }, + apiGateways: { + list: { + url: 'https://apigateway.googleapis.com/v1/projects/{projectId}/locations/{locationId}/gateways', + location: 'region', + dataKey: 'gateways', + isDataArray: true + } + }, + api: { + list: { + url: 'https://apigateway.googleapis.com/v1/projects/{projectId}/locations/{locationId}/apis', + location: 'region', + dataKey: 'apis', + isDataArray: true + } + }, images: { list: { url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/global/images', @@ -319,6 +363,13 @@ var calls = { url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/aggregated/instanceGroups', location: null, pagination: true + }, + list : { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/zones/{locationId}/instanceGroups', + location: 'zone', + pagination: true, + ignoreMiscData: true + } }, instanceGroupManagers: { @@ -339,6 +390,18 @@ var calls = { enabled: true } }, + functionsv2: { + list: { + url: 'https://cloudfunctions.googleapis.com/v2/projects/{projectId}/locations/{locationId}/functions', + location: 'region', + paginationKey: 'pageSize', + pagination: true, + dataFilterKey: 'functions' + }, + sendIntegration: { + enabled: true + } + }, keyRings: { list: { url: 'https://cloudkms.googleapis.com/v1/projects/{projectId}/locations/{locationId}/keyRings', @@ -357,11 +420,24 @@ var calls = { }, backendServices: { list: { - url: 'https://compute.googleapis.com/compute/beta/projects/{projectId}/global/backendServices', - location: null, - pagination: true + globalURL: 'https://compute.googleapis.com/compute/beta/projects/{projectId}/global/backendServices', + url: 'https://compute.googleapis.com/compute/beta/projects/{projectId}/regions/{locationId}/backendServices', + location: 'region', + pagination: true, + ignoreMiscData: true + }, + }, + + forwardingRules: { + list: { + url: 'https://compute.googleapis.com/compute/beta/projects/{projectId}/regions/{locationId}/forwardingRules', + globalURL: 'https://compute.googleapis.com/compute/beta/projects/{projectId}/global/forwardingRules', + location: 'region', + pagination: true, + ignoreMiscData: true }, }, + healthChecks: { list: { url: 'https://compute.googleapis.com/compute/beta/projects/{projectId}/global/healthChecks', @@ -379,9 +455,20 @@ var calls = { }, targetHttpProxies: { list: { - url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/global/targetHttpProxies', - location: null, - pagination: true + url: 'https://compute.googleapis.com/compute/beta/projects/{projectId}/regions/{locationId}/targetHttpProxies', + globalURL: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/global/targetHttpProxies', + location: 'region', + pagination: true, + ignoreMiscData: true, + } + }, + targetHttpsProxies: { + list: { + url: 'https://compute.googleapis.com/compute/beta/projects/{projectId}/regions/{locationId}/targetHttpsProxies', + globalURL: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/global/targetHttpsProxies', + location: 'region', + pagination: true, + ignoreMiscData: true } }, autoscalers: { @@ -441,7 +528,8 @@ var calls = { url: 'https://cloudbuild.clients6.google.com/v1/projects/{projectId}/locations/{locationId}/triggers', location: 'region', dataFilterKey: 'triggers' - } + }, + sendIntegration: serviceMap['CloudBuild'] }, managedZones: { list: { @@ -544,12 +632,14 @@ var calls = { pagination: false } }, - urlMaps: { // https://compute.googleapis.com/compute/v1/projects/{project}/global/urlMaps + urlMaps: { // https://compute.googleapis.com/compute/v1/projects/{projectid}/global/urlMaps list: { - url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/global/urlMaps', - location: null, + globalURL: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/global/urlMaps', + url: 'https://compute.googleapis.com/compute/beta/projects/{projectId}/regions/{locationId}/urlMaps', + location: 'region', pagination: true, - nameRequired: true + nameRequired: true, + ignoreMiscData: true }, sendIntegration: serviceMap['CLB'] }, @@ -587,7 +677,8 @@ var calls = { listDatasets: { url: 'https://{locationId}-aiplatform.googleapis.com/v1/projects/{projectId}/locations/{locationId}/datasets', location: 'region', - dataKey: 'datasets' + dataKey: 'datasets', + isDataArray: true }, listModels: { url: 'https://{locationId}-aiplatform.googleapis.com/v1/projects/{projectId}/locations/{locationId}/models', @@ -719,6 +810,24 @@ var postcalls = { } }, }, + apiConfigs: { + list: { + url: 'https://apigateway.googleapis.com/v1/{name}/configs', + location: 'region', + reliesOnService: ['api'], + reliesOnCall: ['list'], + properties: ['name'], + } + }, + apiGateways: { + getIamPolicy: { + url: 'https://apigateway.googleapis.com/v1/{name}:getIamPolicy', + location: 'region', + reliesOnService: ['apiGateways'], + reliesOnCall: ['list'], + properties: ['name'], + } + }, datasets: { get: { url: 'https://bigquery.googleapis.com/bigquery/v2/projects/{projectId}/datasets/{datasetId}', @@ -753,6 +862,17 @@ var postcalls = { properties: ['name'] } }, + functionsv2: { + getIamPolicy: { + url: 'https://cloudfunctions.googleapis.com/v2/{name}:getIamPolicy', + location: null, + method: 'POST', + reliesOnService: ['functionsv2'], + reliesOnCall: ['list'], + properties: ['name'], + body: { options: { requestedPolicyVersion: 3 } } + } + }, jobs: { get: { //https://dataflow.googleapis.com/v1b3/projects/{projectId}/jobs/{jobId} url: 'https://dataflow.googleapis.com/v1b3/projects/{projectId}/locations/{locationId}/jobs/{id}', @@ -763,6 +883,24 @@ var postcalls = { pagination: false, } }, + instanceGroups: { + listInstances: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/{location}/instanceGroups/{name}/listInstances', + method: 'POST', + reliesOnService: ['instanceGroups'], + reliesOnCall: ['aggregatedList'], + properties: ['location','name'], + filterObjKey: 'instanceGroups' + }, + get: { + url: 'https://compute.googleapis.com/compute/v1/projects/{projectId}/{location}/instanceGroups/{name}', + reliesOnService: ['instanceGroups'], + reliesOnCall: ['aggregatedList'], + properties: ['location','name'], + filterObjKey: 'instanceGroups' + + } + }, organizations: { //https://cloudresourcemanager.googleapis.com/v1beta1/{resource=organizations/*}:getIamPolicy getIamPolicy: { url:'https://cloudresourcemanager.googleapis.com/v1/organizations/{organizationId}:getIamPolicy', @@ -794,7 +932,17 @@ var postcalls = { properties: ['organizationId'], pagination: true, paginationKey: 'pageSize' - } + }, + listAccessPolicies: { + url: 'https://accesscontextmanager.googleapis.com/v1/accessPolicies?parent=organizations/{organizationId}', + reliesOnService: ['organizations'], + reliesOnCall: ['list'], + properties: ['organizationId'], + pagination: true, + paginationKey: 'pageSize', + dataKey: 'accessPolicies' + }, + sendIntegration: serviceMap['Resource Manager'] }, folders:{ // https://cloudresourcemanager.googleapis.com/v2/folders list: { @@ -978,6 +1126,17 @@ var tertiarycalls = { method: 'GET', pagination: false }, + }, + organizations: { + servicePerimeters: { + url: 'https://accesscontextmanager.googleapis.com/v1/{name}/servicePerimeters', + reliesOnService: ['organizations'], + reliesOnCall: ['listAccessPolicies'], + properties: ['name'], + method: 'GET', + pagination: true, + dataKey: 'servicePerimeters' + } } }; diff --git a/helpers/google/functions.js b/helpers/google/functions.js index f8b9367bef..f7e0a90b2b 100644 --- a/helpers/google/functions.js +++ b/helpers/google/functions.js @@ -412,23 +412,24 @@ function checkFirewallRules(firewallRules) { const networkName = firewallRule.network ? firewallRule.network.split('/').pop() : ''; - let allSources = firewallRule.sourceRanges && firewallRule.sourceRanges.some(sourceAddressPrefix => + let allSources = firewallRule.sourceRanges? firewallRule.sourceRanges.some(sourceAddressPrefix => sourceAddressPrefix === '*' || sourceAddressPrefix === '0.0.0.0/0' || sourceAddressPrefix === '::/0' || sourceAddressPrefix.includes('/0') || sourceAddressPrefix.toLowerCase() === 'internet' || sourceAddressPrefix.includes('/0') - ); + ): false; - if (allSources && firewallRule.allowed && firewallRule.allowed.some(allow => !!allow.IPProtocol)) { - return { exposed: true, networkName: `vpc ${networkName}` }; + var allowed = firewallRule.allowed? firewallRule.allowed.some(allow => !!allow.IPProtocol): false; + var denied = firewallRule.denied? firewallRule.denied.some(deny => deny.IPProtocol === 'all'): false; + if (allSources && allowed) { + return {exposed: true, networkName: `vpc ${networkName}`}; } - - if (allSources && firewallRule.denied && firewallRule.denied.some(deny => deny.IPProtocol === 'all')) { - return { exposed: false }; + + if (allSources && denied) { + return {exposed: false}; } - } return {exposed: true}; @@ -436,7 +437,130 @@ function checkFirewallRules(firewallRules) { } -function checkNetworkExposure(cache, source, networks, firewallRules, region, results) { +function getForwardingRules(cache, source, region, resource) { + let rules = []; + + let forwardingRules = getAllDataForService(cache, source, 'forwardingRules', 'list', region); + let backendServices = getAllDataForService(cache, source, 'backendServices', 'list', region); + let targetHttpProxies = getAllDataForService(cache, source, 'targetHttpProxies', 'list', region); + let targetHttpsProxies = getAllDataForService(cache, source, 'targetHttpsProxies', 'list', region); + let urlMaps = getAllDataForService(cache, source, 'urlMaps', 'list', region); + + if (!forwardingRules || !forwardingRules.length || !backendServices || !backendServices.length) { + return []; + } + + if (resource.httpsTrigger && resource.httpsTrigger.url) { + backendServices = backendServices.filter(service => { + if (service.backends && service.backends.length) { + return service.backends.some(backend => backend.target && backend.target.includes(resource.httpsTrigger.url)); + } + }); + } else { + backendServices = backendServices.filter(service => { + if (service.backends && service.backends.length) { + return service.backends.some(backend => { + let group = backend.group.replace(/^.*?(\/projects\/.*)$/, '$1'); + return (resource.selfLink && resource.selfLink.includes(group)); + }); + } + }); + } + + if (backendServices && backendServices.length) { + forwardingRules.forEach(rule => { + let rulePath = `FR ${rule.name}`; + let targetProxyLink = ''; + let urlMapLink = ''; + let backendServiceLink = ''; + + if (rule.target && (rule.target.includes('targetHttpProxies') || rule.target.includes('targetHttpsProxies'))) { + let target = rule.target.replace(/^.*?(\/projects\/.*)$/, '$1'); + let targetProxy = rule.target.includes('targetHttpProxies') + ? targetHttpProxies.find(proxy => proxy.selfLink.includes(target)) + : targetHttpsProxies.find(proxy => proxy.selfLink.includes(target)); + + if (targetProxy) { + rulePath += ` > TP ${targetProxy.name}`; + targetProxyLink = targetProxy.selfLink; + + if (targetProxy.urlMap) { + let urlMap = urlMaps.find(map => map.selfLink.includes(targetProxy.urlMap.replace(/^.*?(\/projects\/.*)$/, '$1'))); + if (urlMap && urlMap.defaultService) { + rulePath += ` > UM ${urlMap.name}`; + urlMapLink = urlMap.selfLink; + + let serviceName = urlMap.defaultService.replace(/^.*?(\/projects\/.*)$/, '$1'); + let matchedBackendService = backendServices.find(service => service.selfLink.includes(serviceName)); + + if (matchedBackendService) { + rulePath += ` > BS ${matchedBackendService.name}`; + backendServiceLink = matchedBackendService.selfLink; + } + } + } else { + let matchedBackendService = backendServices.find(service => targetProxy.selfLink.includes(service.selfLink.replace(/^.*?(\/projects\/.*)$/, '$1'))); + + if (matchedBackendService) { + rulePath += ` > BS ${matchedBackendService.name}`; + backendServiceLink = matchedBackendService.selfLink; + } + } + } + } else if (rule.backendService) { + let serviceName = rule.backendService.replace(/^.*?(\/projects\/.*)$/, '$1'); + let matchedBackendService = backendServices.find(service => service.selfLink.includes(serviceName)); + + if (matchedBackendService) { + rulePath += ` > BS ${matchedBackendService.name}`; + backendServiceLink = matchedBackendService.selfLink; + } + } + + if (backendServiceLink) { + rules.push({ ...rule, rulePath }); + } + }); + } + return rules; +} + + +function getAllDataForService(cache, source, service, call, region) { + let allData = []; + + let globalData = shared.addSource(cache, source, [service, call, 'global']); + let regionalData = shared.addSource(cache, source, [service, call, region]); + + + if (globalData && !globalData.err && globalData.data && globalData.data.length) { + allData = allData.concat(globalData.data); + } + + if (regionalData && !regionalData.err && regionalData.data && regionalData.data.length) { + allData = allData.concat(regionalData.data); + } + return allData; +} + +function checkClusterExposure(cluster) { + const privateClusterConfig = cluster.privateClusterConfig || {}; + const masterAuthorizedNetworksConfig = cluster.masterAuthorizedNetworksConfig || {}; + + const cidrBlocks = masterAuthorizedNetworksConfig.cidrBlocks || []; + + const publicCidrPatterns = ['0.0.0.0/0', '::/0', '*', '::0']; + + const hasPublicCIDR = cidrBlocks.some(block => publicCidrPatterns.includes(block)); + + return ( + (!privateClusterConfig.enablePrivateEndpoint && privateClusterConfig.publicEndpoint) || // Public endpoint with no private endpoint + masterAuthorizedNetworksConfig.gcpPublicCidrsAccessEnabled || // Google Cloud public IPs are allowed + hasPublicCIDR // If there is a public CIDR like 0.0.0.0/0, ::/0, or * + ); +} + +function checkNetworkExposure(cache, source, networks, firewallRules, region, results, forwardingRules) { let exposedPath = ''; if (firewallRules && firewallRules.length) { @@ -446,6 +570,21 @@ function checkNetworkExposure(cache, source, networks, firewallRules, region, re if (isExposed.networkName) { return isExposed.networkName; } + } else { + return ''; + } + } + + // load balancing flow + if (forwardingRules && forwardingRules.length) { + for (let rule of forwardingRules) { + let ipAddress = rule.IPAddress; + if ((rule.loadBalancingScheme === 'EXTERNAL' || rule.loadBalancingScheme === 'EXTERNAL_MANAGED') && + (ipAddress && !ipAddress.startsWith('10.') && !ipAddress.startsWith('192.168.') && !ipAddress.startsWith('172.'))) { + exposedPath = rule.rulePath || rule.name; + break; + + } } } return exposedPath @@ -462,5 +601,8 @@ module.exports = { checkOrgPolicy: checkOrgPolicy, checkIAMRole: checkIAMRole, findOpenAllPortsEgress: findOpenAllPortsEgress, - checkNetworkExposure: checkNetworkExposure + checkNetworkExposure: checkNetworkExposure, + getForwardingRules: getForwardingRules, + checkClusterExposure: checkClusterExposure, + checkFirewallRules: checkFirewallRules }; diff --git a/helpers/google/index.js b/helpers/google/index.js index f5cfcdd775..7654dec0f8 100644 --- a/helpers/google/index.js +++ b/helpers/google/index.js @@ -112,10 +112,10 @@ var run = function(GoogleConfig, collection, settings, service, callObj, callKey if (callObj.reliesOnService[reliedService] && (!collection[callObj.reliesOnService[reliedService]] || - !collection[callObj.reliesOnService[reliedService]][myEngine][callObj.reliesOnCall[reliedService]] || - !collection[callObj.reliesOnService[reliedService]][myEngine][callObj.reliesOnCall[reliedService]][region] || - !collection[callObj.reliesOnService[reliedService]][myEngine][callObj.reliesOnCall[reliedService]][region].data || - !collection[callObj.reliesOnService[reliedService]][myEngine][callObj.reliesOnCall[reliedService]][region].data.length)) return regionCb(); + !collection[callObj.reliesOnService[reliedService]][myEngine][callObj.reliesOnCall[reliedService]] || + !collection[callObj.reliesOnService[reliedService]][myEngine][callObj.reliesOnCall[reliedService]][region] || + !collection[callObj.reliesOnService[reliedService]][myEngine][callObj.reliesOnCall[reliedService]][region].data || + !collection[callObj.reliesOnService[reliedService]][myEngine][callObj.reliesOnCall[reliedService]][region].data.length)) return regionCb(); records = collection[callObj.reliesOnService[reliedService]][myEngine][callObj.reliesOnCall[reliedService]][region].data; if (callObj.subObj) records = records.filter(record => !!record[callObj.subObj]); @@ -154,12 +154,31 @@ var run = function(GoogleConfig, collection, settings, service, callObj, callKey if (callObj.reliesOnService[reliedService] && (!collection[callObj.reliesOnService[reliedService]] || - !collection[callObj.reliesOnService[reliedService]][callObj.reliesOnCall[reliedService]] || - !collection[callObj.reliesOnService[reliedService]][callObj.reliesOnCall[reliedService]][region] || - !collection[callObj.reliesOnService[reliedService]][callObj.reliesOnCall[reliedService]][region].data || - !collection[callObj.reliesOnService[reliedService]][callObj.reliesOnCall[reliedService]][region].data.length)) return regionCb(); + !collection[callObj.reliesOnService[reliedService]][callObj.reliesOnCall[reliedService]] || + !collection[callObj.reliesOnService[reliedService]][callObj.reliesOnCall[reliedService]][region] || + !collection[callObj.reliesOnService[reliedService]][callObj.reliesOnCall[reliedService]][region].data || + !collection[callObj.reliesOnService[reliedService]][callObj.reliesOnCall[reliedService]][region].data.length)) return regionCb(); records = collection[callObj.reliesOnService[reliedService]][callObj.reliesOnCall[reliedService]][region].data; + if (callObj.filterObjKey && records.length === 1) records = records[0]; + + if (records && typeof records === 'object' && !Array.isArray(records) && callObj.filterObjKey) { + let callObjData = []; + Object.keys(records).map(key => { + if (records[key] && records[key][callObj.filterObjKey]) { + if (Array.isArray(records[key][callObj.filterObjKey])) { + let recordsToPush = records[key][callObj.filterObjKey]; + recordsToPush = recordsToPush.map(obj => { + obj.location = key; + return obj; + + }); + callObjData.push(...recordsToPush); + } + } + }); + records = callObjData; + } if (callObj.subObj) records = records.filter(record => !!record[callObj.subObj]); async.eachLimit(records, callObj.maxLimit ? 35 : 10, function(record, recordCb) { callObj.urlToCall = callObj.url; @@ -287,7 +306,7 @@ var execute = async function(LocalGoogleConfig, collection, service, callObj, ca resultItems = setData(collectionItems, data.data[callObj.dataKey], postCall, parent, {'service': service, 'callKey': callKey, maxLimit: callObj.maxLimit}); } else if (data.data.clusters && ['kubernetes', 'dataproc'].includes(service)) { resultItems = setData(collectionItems, data.data['clusters'], postCall, parent, {'service': service, 'callKey': callKey, maxLimit: callObj.maxLimit}); - } else if (callObj.dataKey && data.data && data.data.length && service == 'vertexAI') { + } else if (callObj.dataKey && data.data && data.data.length && callObj.isDataArray) { resultItems = setData(collectionItems, data.data[0][callObj.dataKey], postCall, parent, {'service': service, 'callKey': callKey, maxLimit: callObj.maxLimit}); } else if (callObj.dataFilterKey && data.data[callObj.dataFilterKey]) { resultItems = setData(collectionItems, data.data[callObj.dataFilterKey], postCall, parent, {'service': service, 'callKey': callKey, maxLimit: callObj.maxLimit}); @@ -326,13 +345,15 @@ var execute = async function(LocalGoogleConfig, collection, service, callObj, ca if (callObj.url || callObj.urlToCall) { let url = callObj.urlToCall ? callObj.urlToCall : callObj.url; + if (region === 'global' && callObj.globalURL) { + url = callObj.globalURL; + } url = url.replace('{projectId}', LocalGoogleConfig.project); if (callObj.location && callObj.location == 'zone') { url = url.replace('{locationId}', callObj.params.zone); } else if (callObj.location && callObj.location == 'region') { url = url.replace(/{locationId}/g, callObj.params.region); } - makeApiCall(client, url, executorCb, null, {method: callObj.method, isPostCall, parentRecord, pagination: callObj.pagination, paginationKey: callObj.paginationKey, reqParams: callObj.reqParams, dataKey: callObj.dataKey, body: callObj.body}); } }; diff --git a/helpers/google/regions.js b/helpers/google/regions.js index f0d81c646a..8c68e69487 100644 --- a/helpers/google/regions.js +++ b/helpers/google/regions.js @@ -104,7 +104,7 @@ module.exports = { composer: [ 'us-west1', 'us-west2', 'us-west3', 'us-west4', 'us-central1', 'us-east1', 'us-east4', 'northamerica-northeast1', 'southamerica-east1', 'europe-west2', 'europe-west1', 'europe-west6', 'europe-west3', 'europe-central2', 'asia-south1', 'asia-southeast1', 'asia-east2', 'asia-northeast1', - 'asia-northeast2', 'australia-southeast1', 'asia-northeast3' + 'asia-northeast2', 'australia-southeast1', 'asia-northeast3','asia-east1' ], instanceGroupManagers: regions, functions: [ @@ -112,6 +112,11 @@ module.exports = { 'europe-west1', 'europe-west2', 'europe-west3', 'europe-west6', 'europe-central2', 'asia-south1', 'asia-southeast1', 'asia-southeast2', 'asia-east1', 'asia-east2', 'asia-northeast1', 'asia-northeast2', 'asia-northeast3', 'australia-southeast1' ], + functionsv2: [ + 'us-east1', 'us-east4', 'us-west1', 'us-west2', 'us-west3', 'us-west4', 'us-central1', 'northamerica-northeast1', 'southamerica-east1', + 'europe-west1', 'europe-west2', 'europe-west3', 'europe-west6', 'europe-central2', 'asia-south1', 'asia-southeast1', 'asia-southeast2', + 'asia-east1', 'asia-east2', 'asia-northeast1', 'asia-northeast2', 'asia-northeast3', 'australia-southeast1' + ], cloudbuild: ['global', 'us-east1', 'us-east4', 'us-west2', 'us-west3', 'us-west4', 'us-central1', 'us-west1', 'northamerica-northeast1', 'northamerica-northeast2', 'southamerica-east1', 'southamerica-west1', 'europe-west1', 'europe-west2', 'europe-west3', 'europe-west4', 'europe-west6', 'europe-central2', 'europe-north1', 'asia-south1', 'asia-south2', 'asia-southeast1', 'asia-southeast2', @@ -119,9 +124,11 @@ module.exports = { ], instanceTemplates: ['global'], networks: ['global'], - backendServices: ['global'], + backendServices: ['global', ...regions], + forwardingRules: ['global', ...regions], healthChecks: ['global'], - targetHttpProxies: ['global'], + targetHttpProxies: ['global', ...regions], + targetHttpsProxies: ['global', ...regions], instanceGroups: ['global'], autoscalers: ['global'], subnetworks: regions, @@ -148,13 +155,16 @@ module.exports = { memberships: ['global'], iam: ['global'], deployments: ['global'], - urlMaps: ['global'], + urlMaps: ['global',...regions], apiKeys: ['global'], resourceRecordSets: ['global'], services: ['global'], accessApproval: ['global'], networkRoutes: ['global'], roles: ['global'], + apiGateways: ['global','asia-northeast1', 'australia-southeast1', 'europe-west1', 'europe-west2', 'us-central1', 'us-east1', 'us-east4', 'us-west2', 'us-west3', 'us-west4'], + api: ['global','asia-northeast1', 'australia-southeast1', 'europe-west1', 'europe-west2', 'us-central1', 'us-east1', 'us-east4', 'us-west2', 'us-west3', 'us-west4'], + apiConfigs: ['global','asia-northeast1', 'australia-southeast1', 'europe-west1', 'europe-west2', 'us-central1', 'us-east1', 'us-east4', 'us-west2', 'us-west3', 'us-west4'], vertexAI: ['us-west1', 'us-west2', 'us-west3', 'us-west4', 'us-central1', 'us-east1', 'us-east4', 'us-south1', 'northamerica-northeast1', 'northamerica-northeast2', 'southamerica-east1', 'southamerica-west1', 'europe-west1', 'europe-west2', 'europe-west3', 'europe-west4', 'europe-west6', 'europe-west8', 'europe-west9', 'europe-north1', 'europe-central2', diff --git a/helpers/google/resources.js b/helpers/google/resources.js index 54b7c6feed..0056244717 100644 --- a/helpers/google/resources.js +++ b/helpers/google/resources.js @@ -52,6 +52,10 @@ module.exports = { functions: { list: 'name' }, + functionsv2: { + list: 'name', + getIamPolicy: 'name' + }, instanceGroups: { aggregatedList: '' }, diff --git a/helpers/shared.js b/helpers/shared.js index f230eb3c4f..f789dabb48 100644 --- a/helpers/shared.js +++ b/helpers/shared.js @@ -27,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()] = {}; diff --git a/plugins/alibaba/ram/accessKeysRotation.spec.js b/plugins/alibaba/ram/accessKeysRotation.spec.js index 591c8b6085..a741f77439 100644 --- a/plugins/alibaba/ram/accessKeysRotation.spec.js +++ b/plugins/alibaba/ram/accessKeysRotation.spec.js @@ -24,7 +24,7 @@ const getUserLoginProfile = [ AccessKey: [ { Status: "Active", - AccessKeyId: "LTAI5tD6ekrSssrWq5rNa4JQ", + AccessKeyId: "LTABCDEHJJH", CreateDate: failDate, }, ], @@ -35,7 +35,7 @@ const getUserLoginProfile = [ AccessKey: [ { Status: "Active", - AccessKeyId: "LTAI5tD6ekrSssrWq5rNa4JQ", + AccessKeyId: "LTABCDEHJJ", CreateDate: passDate, }, ], @@ -138,4 +138,4 @@ describe('accessKeysRotation', function () { }); }); }) -}) \ No newline at end of file +}) diff --git a/plugins/aws/accessanalyzer/accessAnalyzerActiveFindings.js b/plugins/aws/accessanalyzer/accessAnalyzerActiveFindings.js index 93d9f22b35..ca801a7e3d 100644 --- a/plugins/aws/accessanalyzer/accessAnalyzerActiveFindings.js +++ b/plugins/aws/accessanalyzer/accessAnalyzerActiveFindings.js @@ -11,15 +11,15 @@ module.exports = { 'You can view IAM Access Analyzer findings at any time. Work through all of the findings in your account until you have zero active findings.', link: 'https://docs.aws.amazon.com/IAM/latest/UserGuide/access-analyzer-work-with-findings.html', recommended_action: 'Investigate into active findings in your account and do the needful until you have zero active findings.', - apis: ['AccessAnalyzer:listAnalyzers', 'AccessAnalyzer:listFindings'], + apis: ['AccessAnalyzer:listAnalyzers', 'AccessAnalyzer:listFindings', 'AccessAnalyzer:listFindingsV2'], realtime_triggers: ['accessanalyzer:CreateAnalyzer','accessanalyzer:DeleteAnalyzer','accessanalyzer:CreateArchiveRule','accessanalyzer:StartResourceScan'], - - run: function(cache, settings, callback) { - var results = []; - var source = {}; + + run: function(cache, settings, callback) { + var results = []; + var source = {}; var regions = helpers.regions(settings); - async.each(regions.accessanalyzer, function(region, rcb){ + async.each(regions.accessanalyzer, function(region, rcb){ var listAnalyzers = helpers.addSource(cache, source, ['accessanalyzer', 'listAnalyzers', region]); @@ -40,19 +40,32 @@ module.exports = { if (!analyzer.arn) continue; let resource = analyzer.arn; + let totalFiltered = []; var listFindings = helpers.addSource(cache, source, ['accessanalyzer', 'listFindings', region, analyzer.arn]); - if (!listFindings || listFindings.err || !listFindings.data) { + if (listFindings && !listFindings.err && listFindings.data) { + let filtered = listFindings.data.findings.filter(finding => finding.status === 'ACTIVE'); + totalFiltered = totalFiltered.concat(filtered); + } + + var listFindingsV2 = helpers.addSource(cache, source, + ['accessanalyzer', 'listFindingsV2', region, analyzer.arn]); + + if (listFindingsV2 && !listFindingsV2.err && listFindingsV2.data) { + let filteredv2 = listFindingsV2.data.findings.filter(finding => finding.status === 'ACTIVE'); + totalFiltered = totalFiltered.concat(filteredv2); + } + + if ((!listFindings || listFindings.err || !listFindings.data) && (!listFindingsV2 || listFindingsV2.err || !listFindingsV2.data)) { helpers.addResult(results, 3, - `Unable to IAM Access Analyzer findings: ${helpers.addError(listFindings)}`, + `Unable to IAM Access Analyzer findings: ${helpers.addError(listFindings)} ${helpers.addError(listFindingsV2)}`, region, resource); continue; - } - - let filtered = listFindings.data.findings.filter(finding => finding.status === 'ACTIVE'); - if (!filtered.length) { + } + + if (!totalFiltered.length) { helpers.addResult(results, 0, 'Amazon IAM Access Analyzer has no active findings', region, resource); diff --git a/plugins/aws/accessanalyzer/accessAnalyzerActiveFindings.spec.js b/plugins/aws/accessanalyzer/accessAnalyzerActiveFindings.spec.js index ce47e5ea8d..649f0572d2 100644 --- a/plugins/aws/accessanalyzer/accessAnalyzerActiveFindings.spec.js +++ b/plugins/aws/accessanalyzer/accessAnalyzerActiveFindings.spec.js @@ -15,8 +15,8 @@ const listAnalyzers = [ ]; const listFindings = [ -{ - "findings": [ + { + "findings": [ { "action": [ "kms:RetireGrant" @@ -74,9 +74,9 @@ const listFindings = [ "updatedAt": "2022-01-12T13:48:20+00:00" } ] -}, -{ - "findings": [ + }, + { + "findings": [ { "action": [ "kms:RetireGrant" @@ -134,10 +134,87 @@ const listFindings = [ "updatedAt": "2022-01-12T13:48:20+00:00" } ] -} - + } + ]; +const listFindingsV2 = [ + { + "findings": [ + { + "analyzedAt": "2025-01-23T13:06:24+00:00", + "createdAt": "2025-01-23T13:06:56+00:00", + "id": "1a234567-bc6d-7yui-h5j7-4f5f9j8987y0", + "resource": "arn:aws:iam::123456789123:role/abcd-abcd-adfitoui-abcdefg-p1-AsdfghTfjdudnjkDkjg-Z9JgMyMzcxOZ", + "resourceType": "AWS::IAM::Role", + "resourceOwnerAccount": "123456789123", + "status": "ACTIVE", + "updatedAt": "2025-01-23T13:06:56+00:00", + "findingType": "UnusedIAMRole" + }, + { + "analyzedAt": "2025-01-23T13:06:24+00:00", + "createdAt": "2025-01-23T13:06:56+00:00", + "id": "938r4848-4h4j-8449-76d8-8768dh5dhh4u", + "resource": "arn:aws:iam::123456789123:role/abcd-abcd-adfitoui-abcdefg-AsdfghTfjdudnjkDkjg-6vzrTVSqTaNe", + "resourceType": "AWS::IAM::Role", + "resourceOwnerAccount": "123456789123", + "status": "ACTIVE", + "updatedAt": "2025-01-23T13:06:56+00:00", + "findingType": "UnusedIAMRole" + }, + { + "analyzedAt": "2025-01-23T13:06:55+00:00", + "createdAt": "2025-01-23T13:06:56+00:00", + "id": "7484f848-984j-498l-784s-yryh74748f45", + "resource": "arn:aws:iam::123456789123:role/service-role/sdfghyFj-FGH-njkkjg-plgd-6uhjn9ok", + "resourceType": "AWS::IAM::Role", + "resourceOwnerAccount": "123456789123", + "status": "ACTIVE", + "updatedAt": "2025-01-23T13:06:56+00:00", + "findingType": "UnusedPermission" + }, + ] + }, + { + "findings": [ + { + "analyzedAt": "2025-01-23T13:06:24+00:00", + "createdAt": "2025-01-23T13:06:56+00:00", + "id": "1a234567-bc6d-7yui-h5j7-4f5f9j8987y0", + "resource": "arn:aws:iam::123456789123:role/abcd-abcd-adfitoui-abcdefg-p1-AsdfghTfjdudnjkDkjg-Z9JgMyMzcxOZ", + "resourceType": "AWS::IAM::Role", + "resourceOwnerAccount": "123456789123", + "status": "ARCHIVED", + "updatedAt": "2025-01-23T13:06:56+00:00", + "findingType": "UnusedIAMRole" + }, + { + "analyzedAt": "2025-01-23T13:06:24+00:00", + "createdAt": "2025-01-23T13:06:56+00:00", + "id": "938r4848-4h4j-8449-76d8-8768dh5dhh4u", + "resource": "arn:aws:iam::123456789123:role/abcd-abcd-adfitoui-abcdefg-AsdfghTfjdudnjkDkjg-6vzrTVSqTaNe", + "resourceType": "AWS::IAM::Role", + "resourceOwnerAccount": "123456789123", + "status": "ARCHIVED", + "updatedAt": "2025-01-23T13:06:56+00:00", + "findingType": "UnusedIAMRole" + }, + { + "analyzedAt": "2025-01-23T13:06:55+00:00", + "createdAt": "2025-01-23T13:06:56+00:00", + "id": "7484f848-984j-498l-784s-yryh74748f45", + "resource": "arn:aws:iam::123456789123:role/service-role/sdfghyFj-FGH-njkkjg-plgd-6uhjn9ok", + "resourceType": "AWS::IAM::Role", + "resourceOwnerAccount": "123456789123", + "status": "RESOLVED", + "updatedAt": "2025-01-23T13:06:56+00:00", + "findingType": "UnusedPermission" + }, + ] + } + +] const createCache = (analyzer, listFindings, analyzerErr, listFindingsErr) => { var analyzerArn = (analyzer && analyzer.length) ? analyzer[0].arn: null; @@ -163,7 +240,7 @@ const createCache = (analyzer, listFindings, analyzerErr, listFindingsErr) => { describe('accessAnalyzerActiveFindings', function () { describe('run', function () { - it('should FAIL if Amazon IAM access analyzer has active findings.', function (done) { + it('should FAIL if Amazon IAM access analyzer V1 has active findings.', function (done) { const cache = createCache(listAnalyzers, listFindings[0]); accessAnalyzerActiveFindings.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); @@ -174,14 +251,38 @@ describe('accessAnalyzerActiveFindings', function () { }); }); - it('should PASS if Amazon IAM access analyzer have no active findings.', function (done) { + it('should FAIL if Amazon IAM access analyzer v2 has active findings.', function (done) { + const cache = createCache(listAnalyzers, listFindingsV2[0]); + accessAnalyzerActiveFindings.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('Amazon IAM Access Analyzer has active findings'); + done(); + }); + }); + + it('should PASS if Amazon IAM access analyzer V1 have no active findings.', function (done) { const cache = createCache(listAnalyzers, listFindings[1]); accessAnalyzerActiveFindings.run(cache, {}, (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('Amazon IAM Access Analyzer has no active findings'); - + + done(); + }); + }); + + + it('should PASS if Amazon IAM access analyzer V2 have no active findings.', function (done) { + const cache = createCache(listAnalyzers, listFindingsV2[1]); + accessAnalyzerActiveFindings.run(cache, {}, (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('Amazon IAM Access Analyzer has no active findings'); + done(); }); }); diff --git a/plugins/aws/apigateway/apigatewayV2Authorization.js b/plugins/aws/apigateway/apigatewayV2Authorization.js index eb3ca1f129..974166533f 100644 --- a/plugins/aws/apigateway/apigatewayV2Authorization.js +++ b/plugins/aws/apigateway/apigatewayV2Authorization.js @@ -14,7 +14,6 @@ module.exports = { realtime_triggers: ['ApiGatewayV2:createApi','ApiGatewayV2:deleteApi','ApiGatewayV2:importApi','ApiGatewayV2:createAuthorizer','ApiGatewayV2:deleteAuthorizer'], run: function(cache, settings, callback) { - console.log('here'); var results = []; var source = {}; var regions = helpers.regions(settings); diff --git a/plugins/aws/autoscaling/asgTagPropagation.js b/plugins/aws/autoscaling/asgTagPropagation.js new file mode 100644 index 0000000000..7515e70ef7 --- /dev/null +++ b/plugins/aws/autoscaling/asgTagPropagation.js @@ -0,0 +1,75 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ASG Tag Propagation', + category: 'AutoScaling', + domain: 'Availability', + severity: 'Medium', + description: 'Ensure that EC2 Auto Scaling Groups propagate tags to EC2 instances that it launches.', + more_info: 'Tags can help with managing, identifying, organizing, searching for, and filtering resources. Additionally, tags can help with security and compliance. Tags should be propagated from an Auto Scaling group to the EC2 instances that it launches.', + link: 'https://docs.aws.amazon.com/autoscaling/ec2/userguide/ec2-auto-scaling-tagging.html', + recommended_action: 'Enable tag propagation for all tags on Auto Scaling Groups by setting PropagateAtLaunch to true for each tag.', + apis: ['AutoScaling:describeAutoScalingGroups'], + realtime_triggers: ['autoscaling:CreateAutoScalingGroup', 'autoscaling:UpdateAutoScalingGroup', 'autoscaling:CreateOrUpdateTags', 'autoscaling:DeleteTags'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.autoscaling, function(region, rcb){ + var describeAutoScalingGroups = helpers.addSource(cache, source, + ['autoscaling', 'describeAutoScalingGroups', region]); + + if (!describeAutoScalingGroups) return rcb(); + + if (describeAutoScalingGroups.err || !describeAutoScalingGroups.data) { + helpers.addResult(results, 3, + 'Unable to query for auto scaling groups: ' + + helpers.addError(describeAutoScalingGroups), region); + return rcb(); + } + + if (!describeAutoScalingGroups.data.length) { + helpers.addResult(results, 0, 'No auto scaling groups found', region); + return rcb(); + } + + describeAutoScalingGroups.data.forEach(function(asg){ + var resource = asg.AutoScalingGroupARN; + + if (!resource) return; + + if (!asg.Tags || !asg.Tags.length) { + helpers.addResult(results, 0, + 'Auto scaling group has no tags configured', + region, resource); + return; + } + + var tagsNotPropagating = []; + asg.Tags.forEach(function(tag) { + if (!tag.PropagateAtLaunch) { + tagsNotPropagating.push(tag.Key || 'unnamed'); + } + }); + + if (!tagsNotPropagating.length ) { + helpers.addResult(results, 0, + 'Auto scaling group has all tags configured to propagate to EC2 instances', + region, resource); + } else { + helpers.addResult(results, 2, + 'Auto scaling group has ' + tagsNotPropagating.length + + ' tag(s) not configured to propagate to EC2 instances', + region, resource); + } + }); + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; + diff --git a/plugins/aws/autoscaling/asgTagPropagation.spec.js b/plugins/aws/autoscaling/asgTagPropagation.spec.js new file mode 100644 index 0000000000..53aa7c6413 --- /dev/null +++ b/plugins/aws/autoscaling/asgTagPropagation.spec.js @@ -0,0 +1,236 @@ +var expect = require('chai').expect; +const asgTagPropagation = require('./asgTagPropagation'); + +const autoScalingGroups = [ + { + "AutoScalingGroupName": "asg-all-tags-propagate", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/asg-all-tags-propagate", + "MinSize": 1, + "MaxSize": 3, + "DesiredCapacity": 2, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a", + "us-east-1b" + ], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "Tags": [ + { + "ResourceId": "asg-all-tags-propagate", + "ResourceType": "auto-scaling-group", + "Key": "Environment", + "Value": "Production", + "PropagateAtLaunch": true + }, + { + "ResourceId": "asg-all-tags-propagate", + "ResourceType": "auto-scaling-group", + "Key": "Owner", + "Value": "DevOps", + "PropagateAtLaunch": true + } + ], + "TerminationPolicies": ["Default"] + }, + { + "AutoScalingGroupName": "asg-some-tags-propagate", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/asg-some-tags-propagate", + "MinSize": 1, + "MaxSize": 3, + "DesiredCapacity": 2, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "Tags": [ + { + "ResourceId": "asg-some-tags-propagate", + "ResourceType": "auto-scaling-group", + "Key": "Environment", + "Value": "Production", + "PropagateAtLaunch": true + }, + { + "ResourceId": "asg-some-tags-propagate", + "ResourceType": "auto-scaling-group", + "Key": "Owner", + "Value": "DevOps", + "PropagateAtLaunch": false + } + ], + "TerminationPolicies": ["Default"] + }, + { + "AutoScalingGroupName": "asg-no-tags", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/asg-no-tags", + "MinSize": 1, + "MaxSize": 3, + "DesiredCapacity": 2, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "Tags": [], + "TerminationPolicies": ["Default"] + }, + { + "AutoScalingGroupName": "asg-no-tags-property", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/asg-no-tags-property", + "MinSize": 1, + "MaxSize": 3, + "DesiredCapacity": 2, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "TerminationPolicies": ["Default"] + }, + { + "AutoScalingGroupName": "asg-all-tags-not-propagate", + "AutoScalingGroupARN": "arn:aws:autoscaling:us-east-1:111122223333:autoScalingGroup:e83ceb12-2760-4a92-a374-3df611331bdc:autoScalingGroupName/asg-all-tags-not-propagate", + "MinSize": 1, + "MaxSize": 3, + "DesiredCapacity": 2, + "DefaultCooldown": 300, + "AvailabilityZones": [ + "us-east-1a" + ], + "HealthCheckType": "EC2", + "HealthCheckGracePeriod": 300, + "Instances": [], + "CreatedTime": "2020-08-18T23:12:00.954Z", + "Tags": [ + { + "ResourceId": "asg-all-tags-not-propagate", + "ResourceType": "auto-scaling-group", + "Key": "Environment", + "Value": "Production", + "PropagateAtLaunch": false + }, + { + "ResourceId": "asg-all-tags-not-propagate", + "ResourceType": "auto-scaling-group", + "Key": "Owner", + "Value": "DevOps", + "PropagateAtLaunch": false + } + ], + "TerminationPolicies": ["Default"] + } +]; + +const createCache = (asgs) => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + data: asgs + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + autoscaling: { + describeAutoScalingGroups: { + 'us-east-1': { + err: { + message: 'error describing Auto Scaling groups' + }, + }, + }, + }, + }; +}; + +describe('asgTagPropagation', function () { + describe('run', function () { + it('should PASS if all tags have PropagateAtLaunch set to true', function (done) { + const cache = createCache([autoScalingGroups[0]]); + asgTagPropagation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('all tags configured to propagate'); + done(); + }); + }); + + it('should FAIL if some tags do not have PropagateAtLaunch set to true', function (done) { + const cache = createCache([autoScalingGroups[1]]); + asgTagPropagation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('1 tag(s) not configured to propagate'); + done(); + }); + }); + + it('should PASS if Auto Scaling group has no tags', function (done) { + const cache = createCache([autoScalingGroups[2]]); + asgTagPropagation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('has no tags configured'); + done(); + }); + }); + + it('should PASS if Auto Scaling group has no Tags property', function (done) { + const cache = createCache([autoScalingGroups[3]]); + asgTagPropagation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('has no tags configured'); + done(); + }); + }); + + it('should FAIL if all tags have PropagateAtLaunch set to false', function (done) { + const cache = createCache([autoScalingGroups[4]]); + asgTagPropagation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('2 tag(s) not configured to propagate'); + done(); + }); + }); + + it('should PASS if no Auto Scaling groups found', function (done) { + const cache = createCache([]); + asgTagPropagation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No auto scaling groups found'); + done(); + }); + }); + + it('should UNKNOWN if error describing Auto Scaling groups', function (done) { + const cache = createErrorCache(); + asgTagPropagation.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query'); + done(); + }); + }); + }); +}); + diff --git a/plugins/aws/cloudfront/cloudfrontLoggingEnabled.js b/plugins/aws/cloudfront/cloudfrontLoggingEnabled.js index a6bf253201..2fca857eba 100644 --- a/plugins/aws/cloudfront/cloudfrontLoggingEnabled.js +++ b/plugins/aws/cloudfront/cloudfrontLoggingEnabled.js @@ -5,14 +5,14 @@ module.exports = { category: 'CloudFront', domain: 'Content Delivery', severity: 'Medium', - description: 'Ensures CloudFront distributions have request logging enabled.', - more_info: 'Logging requests to CloudFront ' + + description: 'Ensures CloudFront distributions have S3 legacy logging enabled.', + more_info: 'Logging S3 legacy to CloudFront ' + 'distributions is a helpful way of detecting and ' + 'investigating potential attacks, malicious activity, ' + - 'or misuse of backend resources. Logs can be sent to S3 ' + + 'or misuse of backend resources. Logs can be sent to S3 ' + 'and processed for further analysis.', - link: 'http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/AccessLogs.html', - recommended_action: 'Enable CloudFront request logging.', + link: 'https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/standard-logging-legacy-s3.html', + recommended_action: 'Enable CloudFront S3 legacy logging.', apis: ['CloudFront:listDistributions', 'CloudFront:getDistribution'], compliance: { hipaa: 'As part of the audit control requirement for HIPAA, request logging for ' + @@ -77,10 +77,10 @@ module.exports = { var logging = getDistribution.data.Distribution.DistributionConfig.Logging; if (logging.Enabled){ helpers.addResult(results, 0, - 'Request logging is enabled', 'global', Distribution.ARN); + 'S3 legacy logging is enabled', 'global', Distribution.ARN); } else { helpers.addResult(results, 2, - 'Request logging is not enabled', 'global', Distribution.ARN); + 'S3 legacy logging is not enabled', 'global', Distribution.ARN); } } }); diff --git a/plugins/aws/configservice/configServiceMissingBucket.js b/plugins/aws/configservice/configServiceMissingBucket.js index 29ea23c917..df3d5c6030 100644 --- a/plugins/aws/configservice/configServiceMissingBucket.js +++ b/plugins/aws/configservice/configServiceMissingBucket.js @@ -19,6 +19,7 @@ module.exports = { var source = {}; var regions = helpers.regions(settings); var awsOrGov = helpers.defaultPartition(settings); + var defaultRegion = helpers.defaultRegion(settings); async.each(regions.configservice, function(region, rcb) { var describeDeliveryChannels = helpers.addSource(cache, source, @@ -36,17 +37,22 @@ module.exports = { helpers.addResult(results, 0, 'No Config delivery channels found', region); return rcb(); } + var listBuckets = helpers.addSource(cache, source, + ['s3', 'listBuckets', defaultRegion]); let deletedBuckets = []; for (let record of describeDeliveryChannels.data) { if (!record.s3BucketName) continue; var headBucket = helpers.addSource(cache, source, - ['s3', 'headBucket', region, record.s3BucketName]); + ['s3', 'headBucket', defaultRegion, record.s3BucketName]); - if (headBucket && headBucket.err && headBucket.err.message && - headBucket.err.message.toLowerCase().includes('not found')){ - deletedBuckets.push(record); + var bucketFound = listBuckets && listBuckets.data && listBuckets.data.length + ? listBuckets.data.some(bucket => bucket.Name === record.s3BucketName) : false; + + if (!bucketFound || (headBucket && headBucket.err && headBucket.err.message && + headBucket.err.message.toLowerCase().includes('not found'))) { + deletedBuckets.push(record.s3BucketName); } else if (!headBucket || headBucket.err) { helpers.addResult(results, 3, 'Unable to query S3 bucket: ' + helpers.addError(headBucket), region, `arn:${awsOrGov}:s3:::` + record.s3BucketName); diff --git a/plugins/aws/configservice/configServiceMissingBucket.spec.js b/plugins/aws/configservice/configServiceMissingBucket.spec.js index 13ebc28a86..98520e3767 100644 --- a/plugins/aws/configservice/configServiceMissingBucket.spec.js +++ b/plugins/aws/configservice/configServiceMissingBucket.spec.js @@ -14,7 +14,7 @@ const describeDeliveryChannels = [ } ]; -const createCache = (records, headBucket, recordsErr, headBucketErr) => { +const createCache = (records, headBucket, recordsErr, headBucketErr, buckets) => { var name = (records && records.length) ? records[0].s3BucketName : null; return { configservice: { @@ -26,6 +26,12 @@ const createCache = (records, headBucket, recordsErr, headBucketErr) => { }, }, s3: { + listBuckets: { + 'us-east-1': { + err: 'err', + data: buckets + } + }, headBucket: { 'us-east-1': { [name]: { @@ -42,7 +48,10 @@ const createCache = (records, headBucket, recordsErr, headBucketErr) => { describe('configServiceMissingBucket', function () { describe('run', function () { it('should PASS if Config Service is not referencing any deleted bucket', function (done) { - const cache = createCache([describeDeliveryChannels[1]], null); + const cache = createCache([describeDeliveryChannels[1]], null, null, null,[{ + "Name": "amazon-connect-e39f272cf1f0", + "CreationDate": "November 22, 2021, 15:51:19 (UTC+05:00)", + }]); configServiceMissingBucket.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); diff --git a/plugins/aws/connect/customerProfilesDomainEncrypted.js b/plugins/aws/connect/customerProfilesDomainEncrypted.js index 76063936c5..ca53ba859a 100644 --- a/plugins/aws/connect/customerProfilesDomainEncrypted.js +++ b/plugins/aws/connect/customerProfilesDomainEncrypted.js @@ -34,10 +34,13 @@ module.exports = { desiredEncryptionLevelString: settings.customer_profiles_desired_encryption_level || this.settings.customer_profiles_desired_encryption_level.default }; + // Skip encryption check if default awskms is set + var skipEncryptionCheck = config.desiredEncryptionLevelString === 'awskms'; + var desiredEncryptionLevel = helpers.ENCRYPTION_LEVELS.indexOf(config.desiredEncryptionLevelString); var currentEncryptionLevel; - async.each(regions.customerprofiles, function(region, rcb){ + async.each(regions.customerprofiles, function(region, rcb){ var listDomains = helpers.addSource(cache, source, ['customerprofiles', 'listDomains', region]); @@ -76,44 +79,54 @@ module.exports = { `Unable to get customerprofiles domain description: ${helpers.addError(getDomain)}`, region, resource); continue; - } - - if (getDomain.data.DefaultEncryptionKey) { - let DefaultEncryptionKey = getDomain.data.DefaultEncryptionKey; - var keyId = DefaultEncryptionKey.split('/')[1] ? DefaultEncryptionKey.split('/')[1] : DefaultEncryptionKey; - - var describeKey = helpers.addSource(cache, source, - ['kms', 'describeKey', region, keyId]); - - if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { - helpers.addResult(results, 3, - `Unable to query KMS key: ${helpers.addError(describeKey)}`, - region, DefaultEncryptionKey); - continue; - } - - currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); - } else { - helpers.addResult(results, 3, - 'Unable to find Customer Profile domain encryption key', region, resource); - continue; } - - var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; - - if (currentEncryptionLevel >= desiredEncryptionLevel) { + if (skipEncryptionCheck) { helpers.addResult(results, 0, - `Customer Profile domain is encrypted with ${currentEncryptionLevelString} \ - which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + 'Customer Profile domain is encrypted with desired encryption level.', region, resource); } else { - helpers.addResult(results, 2, - `Customer Profile domain is encrypted with ${currentEncryptionLevelString} \ - which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, - region, resource); + if (getDomain.data.DefaultEncryptionKey) { + let DefaultEncryptionKey = getDomain.data.DefaultEncryptionKey; + var keyId = DefaultEncryptionKey.split('/')[1] ? DefaultEncryptionKey.split('/')[1] : DefaultEncryptionKey; + + var describeKey = helpers.addSource(cache, source, + ['kms', 'describeKey', region, keyId]); + + if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { + helpers.addResult(results, 3, + `Unable to query KMS key: ${helpers.addError(describeKey)}`, + region, DefaultEncryptionKey); + continue; + } + + currentEncryptionLevel = helpers.getEncryptionLevel(describeKey.data.KeyMetadata, helpers.ENCRYPTION_LEVELS); + } else if (domain.DomainName.startsWith('amazon-connect') && getDomain.data.DefaultEncryptionKey == null) { + helpers.addResult(results, 2, + `Customer Profile domain is encrypted with awskms \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + continue; + } else { + helpers.addResult(results, 3, + 'Unable to find Customer Profile domain encryption key', region, resource); + continue; + } + + var currentEncryptionLevelString = helpers.ENCRYPTION_LEVELS[currentEncryptionLevel]; + + if (currentEncryptionLevel >= desiredEncryptionLevel) { + helpers.addResult(results, 0, + `Customer Profile domain is encrypted with ${currentEncryptionLevelString} \ + which is greater than or equal to the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } else { + helpers.addResult(results, 2, + `Customer Profile domain is encrypted with ${currentEncryptionLevelString} \ + which is less than the desired encryption level ${config.desiredEncryptionLevelString}`, + region, resource); + } } } - rcb(); }, function(){ callback(null, results, source); diff --git a/plugins/aws/documentDB/docdbAuditLoggingEnabled.js b/plugins/aws/documentDB/docdbAuditLoggingEnabled.js new file mode 100644 index 0000000000..82d0d19141 --- /dev/null +++ b/plugins/aws/documentDB/docdbAuditLoggingEnabled.js @@ -0,0 +1,56 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'DocumentDB Audit Logging Enabled', + category: 'DocumentDB', + domain: 'Databases', + severity: 'Medium', + description: 'Ensure that audit logging is enabled for DocumentDB clusters.', + more_info: 'Audit logging in Amazon DocumentDB provides visibility into authentication events, queries, and data changes. It helps detect unauthorized access, supports troubleshooting, and meets compliance requirements. Logs should be sent to CloudWatch or a SIEM for centralized monitoring and alerting.', + recommended_action: 'Modify DocumentDB cluster and enable audit logging feature.', + link: 'https://docs.aws.amazon.com/documentdb/latest/developerguide/profiling.html', + apis: ['DocDB:describeDBClusters'], + realtime_triggers: ['docdb:CreateDBCluster','docdb:ModifyDBCluster','docdb:DeleteDBCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.docdb, function(region, rcb){ + var describeDBClusters = helpers.addSource(cache, source, + ['docdb', 'describeDBClusters', region]); + + if (!describeDBClusters) return rcb(); + + if (describeDBClusters.err || !describeDBClusters.data) { + helpers.addResult(results, 3, + `Unable to list DocumentDB clusters: ${helpers.addError(describeDBClusters)}`, region); + return rcb(); + } + + if (!describeDBClusters.data.length) { + helpers.addResult(results, 0, + 'No DocumentDB clusters found', region); + return rcb(); + } + + for (let cluster of describeDBClusters.data) { + if (!cluster.DBClusterArn) continue; + + if (cluster.EnabledCloudwatchLogsExports && + cluster.EnabledCloudwatchLogsExports.length && + cluster.EnabledCloudwatchLogsExports.includes('audit')) { + helpers.addResult(results, 0, 'DocumentDB cluster has audit logging enabled', region, cluster.DBClusterArn); + } else { + helpers.addResult(results, 2, 'DocumentDB cluster does not have audit logging enabled', region, cluster.DBClusterArn); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/documentDB/docdbAuditLoggingEnabled.spec.js b/plugins/aws/documentDB/docdbAuditLoggingEnabled.spec.js new file mode 100644 index 0000000000..dbac13c129 --- /dev/null +++ b/plugins/aws/documentDB/docdbAuditLoggingEnabled.spec.js @@ -0,0 +1,110 @@ +var expect = require('chai').expect; +var docdbAuditLoggingEnabled = require('./docdbAuditLoggingEnabled'); + +const describeDBClusters = [ + { + AvailabilityZones: [], + BackupRetentionPeriod: 1, + DBClusterArn: 'arn:aws:rds:us-east-1:000011112222:cluster:docdb-2021-11-10-10-16-10', + DBClusterIdentifier: 'docdb-2021-11-10-10-16-10', + DBClusterParameterGroup: 'default.docdb4.0', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + DeletionProtection: true, + EnabledCloudwatchLogsExports: [ "audit", "profiler"] + }, + { + AvailabilityZones: [], + BackupRetentionPeriod: 10, + DBClusterArn: 'arn:aws:rds:us-east-1:000011112223:cluster:docdb-2021-11-10-10-16-10', + DBClusterIdentifier: 'docdb-2021-11-10-10-16-10', + DBClusterParameterGroup: 'default.docdb4.0', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + DeletionProtection: false, + EnabledCloudwatchLogsExports: [ "profiler"] + }, + { + AvailabilityZones: [], + BackupRetentionPeriod: 10, + DBClusterArn: 'arn:aws:rds:us-east-1:000011112224:cluster:docdb-2021-11-10-10-16-10', + DBClusterIdentifier: 'docdb-2021-11-10-10-16-10', + DBClusterParameterGroup: 'default.docdb4.0', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + DeletionProtection: false, + EnabledCloudwatchLogsExports: [] + } +]; + +const createCache = (clusters, clustersErr) => { + return { + docdb: { + describeDBClusters: { + 'us-east-1': { + err: clustersErr, + data: clusters + }, + }, + } + }; +}; + +describe('docdbAuditLoggingEnabled', function () { + describe('run', function () { + it('should PASS if DocumentDB Cluster has audit logging enabled', function (done) { + const cache = createCache([describeDBClusters[0]]); + docdbAuditLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('DocumentDB cluster has audit logging enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if DocumentDB Cluster does not have audit logging enabled', function (done) { + const cache = createCache([describeDBClusters[1]]); + docdbAuditLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('DocumentDB cluster does not have audit logging enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if DocumentDB Cluster has empty EnabledCloudwatchLogsExports', function (done) { + const cache = createCache([describeDBClusters[2]]); + docdbAuditLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('DocumentDB cluster does not have audit logging enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no DocumentDB Clusters found', function (done) { + const cache = createCache([]); + docdbAuditLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No DocumentDB clusters found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list DocumentDB Clusters', function (done) { + const cache = createCache(null, { message: "Unable to list DocumentDB Clusters" }); + docdbAuditLoggingEnabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list DocumentDB clusters:'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/aws/documentDB/docdbEncryptionInTransit.js b/plugins/aws/documentDB/docdbEncryptionInTransit.js new file mode 100644 index 0000000000..5b4f654183 --- /dev/null +++ b/plugins/aws/documentDB/docdbEncryptionInTransit.js @@ -0,0 +1,99 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'DocumentDB Encryption In Transit', + category: 'DocumentDB', + domain: 'Databases', + severity: 'High', + description: 'Ensure that DocumentDB clusters have TLS/SSL encryption in transit enabled.', + more_info: 'DocumentDB uses TLS/SSL to encrypt data during transit. The TLS parameter in the cluster parameter group should be set to enabled to require encrypted connections. This ensures that all data transmitted between clients and the DocumentDB cluster is encrypted.', + recommended_action: 'Modify the cluster parameter group to set the tls parameter to enabled, or create a custom parameter group with TLS enabled and associate it with the cluster.', + link: 'https://docs.aws.amazon.com/documentdb/latest/developerguide/security.encryption.ssl.html', + apis: ['DocDB:describeDBClusters', 'DocDB:describeDBClusterParameters'], + realtime_triggers: [ 'docdb:CreateDBCluster', 'docdb:ModifyDBCluster', 'docdb:ModifyDBClusterParameterGroup', 'docdb:CreateDBClusterParameterGroup','docdb:DeleteDBCluster' ], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.docdb, function(region, rcb){ + var describeDBClusters = helpers.addSource(cache, source, + ['docdb', 'describeDBClusters', region]); + + if (!describeDBClusters) return rcb(); + + if (describeDBClusters.err || !describeDBClusters.data) { + helpers.addResult(results, 3, + `Unable to list DocumentDB clusters: ${helpers.addError(describeDBClusters)}`, region); + return rcb(); + } + + if (!describeDBClusters.data.length) { + helpers.addResult(results, 0, + 'No DocumentDB clusters found', region); + return rcb(); + } + + async.each(describeDBClusters.data, function(cluster, ccb){ + if (!cluster.DBClusterArn || !cluster.DBClusterIdentifier) return ccb(); + + var resource = cluster.DBClusterArn; + var tlsEnabled = false; + + if (!cluster.DBClusterParameterGroup) { + helpers.addResult(results, 2, + 'DocumentDB cluster does not have a parameter group associated', + region, resource); + return ccb(); + } + + var parameterGroupName = cluster.DBClusterParameterGroup; + + + var parameters = helpers.addSource(cache, source, + ['docdb', 'describeDBClusterParameters', region, parameterGroupName]); + + if (!parameters || parameters.err || !parameters.data) { + helpers.addResult(results, 3, + `Unable to query cluster parameters: ${helpers.addError(parameters)}`, + region, resource); + return ccb(); + } + + if (!parameters.data.Parameters) { + helpers.addResult(results, 2, + 'DocumentDB cluster does not have TLS encryption in transit enabled', + region, resource); + return ccb(); + } + + for (var param of parameters.data.Parameters) { + if (param.ParameterName && param.ParameterName === 'tls' && + param.ParameterValue && + (param.ParameterValue.toLowerCase() === 'enabled' || param.ParameterValue === '1')) { + tlsEnabled = true; + break; + } + } + + if (tlsEnabled) { + helpers.addResult(results, 0, + 'DocumentDB cluster has TLS encryption in transit enabled', + region, resource); + } else { + helpers.addResult(results, 2, + 'DocumentDB cluster does not have TLS encryption in transit enabled', + region, resource); + } + + ccb(); + }, function(){ + rcb(); + }); + }, function(){ + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/documentDB/docdbEncryptionInTransit.spec.js b/plugins/aws/documentDB/docdbEncryptionInTransit.spec.js new file mode 100644 index 0000000000..5408d2464b --- /dev/null +++ b/plugins/aws/documentDB/docdbEncryptionInTransit.spec.js @@ -0,0 +1,233 @@ +var expect = require('chai').expect; +var docdbEncryptionInTransit = require('./docdbEncryptionInTransit'); + +const describeDBClusters = [ + { + AvailabilityZones: [], + BackupRetentionPeriod: 7, + DBClusterArn: 'arn:aws:rds:us-east-1:000011112222:cluster:docdb-2021-11-10-10-16-10', + DBClusterIdentifier: 'docdb-2021-11-10-10-16-10', + DBClusterParameterGroup: 'custom-docdb-param-group', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + Engine: 'docdb', + EngineVersion: '4.0.0' + }, + { + AvailabilityZones: [], + BackupRetentionPeriod: 7, + DBClusterArn: 'arn:aws:rds:us-east-1:000011112223:cluster:docdb-2021-11-10-10-16-11', + DBClusterIdentifier: 'docdb-2021-11-10-10-16-11', + DBClusterParameterGroup: 'custom-docdb-param-group-disabled', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + Engine: 'docdb', + EngineVersion: '4.0.0' + }, + { + AvailabilityZones: [], + BackupRetentionPeriod: 7, + DBClusterArn: 'arn:aws:rds:us-east-1:000011112224:cluster:docdb-2021-11-10-10-16-12', + DBClusterIdentifier: 'docdb-2021-11-10-10-16-12', + DBClusterParameterGroup: 'default.docdb4.0', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + Engine: 'docdb', + EngineVersion: '4.0.0' + }, + { + AvailabilityZones: [], + BackupRetentionPeriod: 7, + DBClusterArn: 'arn:aws:rds:us-east-1:000011112225:cluster:docdb-2021-11-10-10-16-13', + DBClusterIdentifier: 'docdb-2021-11-10-10-16-13', + DBSubnetGroup: 'default-vpc-99de2fe4', + Status: 'available', + Engine: 'docdb', + EngineVersion: '4.0.0' + } +]; + +const clusterParameters = { + 'custom-docdb-param-group': { + Parameters: [ + { + ParameterName: 'tls', + ParameterValue: 'enabled', + Description: 'Enable TLS encryption', + Source: 'user', + ApplyType: 'static', + DataType: 'string', + AllowedValues: 'enabled,disabled', + IsModifiable: true + } + ] + }, + 'custom-docdb-param-group-disabled': { + Parameters: [ + { + ParameterName: 'tls', + ParameterValue: 'disabled', + Description: 'Enable TLS encryption', + Source: 'user', + ApplyType: 'static', + DataType: 'string', + AllowedValues: 'enabled,disabled', + IsModifiable: true + } + ] + } +}; + +const createCache = (clusters, clustersErr, parameters, parametersErr) => { + var cache = { + docdb: { + describeDBClusters: { + 'us-east-1': { + err: clustersErr, + data: clusters + }, + }, + } + }; + + if (parameters) { + cache.docdb = cache.docdb || {}; + cache.docdb.describeDBClusterParameters = { + 'us-east-1': {} + }; + for (var groupName in parameters) { + cache.docdb.describeDBClusterParameters['us-east-1'][groupName] = { + err: parametersErr, + data: parameters[groupName] + }; + } + } + + return cache; +}; + +describe('docdbEncryptionInTransit', function () { + describe('run', function () { + it('should PASS if DocumentDB cluster has TLS enabled in custom parameter group', function (done) { + const cache = createCache([describeDBClusters[0]], null, clusterParameters); + docdbEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('has TLS encryption in transit enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if DocumentDB cluster has TLS disabled in parameter group', function (done) { + const cache = createCache([describeDBClusters[1]], null, clusterParameters); + docdbEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have TLS encryption in transit enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if DocumentDB cluster uses default parameter group', function (done) { + const cache = createCache([describeDBClusters[2]], null, { + 'default.docdb4.0': { + Parameters: [ + { + ParameterName: 'tls', + ParameterValue: 'disabled', + Description: 'Enable TLS encryption', + Source: 'system', + ApplyType: 'static', + DataType: 'string', + AllowedValues: 'enabled,disabled', + IsModifiable: false + } + ] + } + }); + docdbEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have TLS encryption in transit enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if DocumentDB cluster has no parameter group', function (done) { + const cache = createCache([describeDBClusters[3]], null); + docdbEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have a parameter group associated'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should PASS if no DocumentDB clusters found', function (done) { + const cache = createCache([]); + docdbEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No DocumentDB clusters found'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to list DocumentDB clusters', function (done) { + const cache = createCache(null, { message: "Unable to list DocumentDB Clusters" }); + docdbEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to list DocumentDB clusters:'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should UNKNOWN if unable to query cluster parameters', function (done) { + const cache = createCache([describeDBClusters[0]], null, null, { message: "Unable to query parameters" }); + docdbEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query cluster parameters'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if cluster parameters data is null', function (done) { + const cache = createCache([describeDBClusters[0]], null, { + 'custom-docdb-param-group': { + Parameters: null + } + }); + docdbEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have TLS encryption in transit enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + + it('should FAIL if cluster parameters array is empty', function (done) { + const cache = createCache([describeDBClusters[0]], null, { + 'custom-docdb-param-group': { + Parameters: [] + } + }); + docdbEncryptionInTransit.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have TLS encryption in transit enabled'); + expect(results[0].region).to.equal('us-east-1'); + done(); + }); + }); + }); +}); diff --git a/plugins/aws/ec2/amiNamingConvention.js b/plugins/aws/ec2/amiNamingConvention.js new file mode 100644 index 0000000000..d3af4fe890 --- /dev/null +++ b/plugins/aws/ec2/amiNamingConvention.js @@ -0,0 +1,84 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'AMI Naming Conventions', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that Amazon Machine Images (AMIs) follow organizational naming conventions for tagging', + more_info: 'AMIs should follow a consistent naming convention using the Name tag to identify their purpose, environment, and region. This helps prevent accidental use of incorrect images, reduces operational errors, and improves resource management. Without proper naming conventions, teams may deploy instances with outdated or inappropriate AMIs, leading to security vulnerabilities or configuration issues.', + link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/Using_Tags.html', + recommended_action: 'Update AMI Name tags to follow organizational naming conventions.', + apis: ['EC2:describeImages'], + settings: { + ami_naming_pattern: { + name: 'AMI Naming Pattern', + description: 'A regex pattern to validate AMI Name tag values. Default: ^ami-(ue1|uw1|uw2|ew1|ec1|an1|an2|as1|as2|se1)-(d|t|s|p)-([a-z0-9\\-]+)$', + regex: '^.*$', + default: '^ami-(ue1|uw1|uw2|ew1|ec1|an1|an2|as1|as2|se1)-(d|t|s|p)-([a-z0-9\\-]+)$' + } + }, + realtime_triggers: ['ec2:CreateImage', 'ec2:CreateTags', 'ec2:DeleteTags', 'ec2:DeregisterImage'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var config = { + ami_naming_pattern: settings.ami_naming_pattern || this.settings.ami_naming_pattern.default + }; + + var namingPattern = new RegExp(config.ami_naming_pattern); + + async.each(regions.ec2, function(region, rcb){ + var describeImages = helpers.addSource(cache, source, + ['ec2', 'describeImages', region]); + + if (!describeImages) return rcb(); + + if (describeImages.err || !describeImages.data) { + helpers.addResult(results, 3, + 'Unable to query for AMIs: ' + helpers.addError(describeImages), region); + return rcb(); + } + + if (!describeImages.data.length) { + helpers.addResult(results, 0, 'No AMIs found', region); + return rcb(); + } + + for (var ami of describeImages.data) { + if (!ami.ImageId) continue; + + const arn = 'arn:' + awsOrGov + ':ec2:' + region + '::image/' + ami.ImageId; + + if (!ami.Tags || !ami.Tags.length) { + helpers.addResult(results, 2, + 'AMI does not have a name tag', region, arn); + continue; + } + + var nameTag = ami.Tags.find(tag => tag.Key === 'Name'); + + if (!nameTag || !nameTag.Value) { + helpers.addResult(results, 2, + 'AMI does not have a name tag', region, arn); + } else if (!namingPattern.test(nameTag.Value)) { + helpers.addResult(results, 2, + `AMI Name tag "${nameTag.Value}" does not follow organizational naming convention`, region, arn); + } else { + helpers.addResult(results, 0, + `AMI Name tag "${nameTag.Value}" follows organizational naming convention`, region, arn); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; + diff --git a/plugins/aws/ec2/amiNamingConvention.spec.js b/plugins/aws/ec2/amiNamingConvention.spec.js new file mode 100644 index 0000000000..e36c7b1ed8 --- /dev/null +++ b/plugins/aws/ec2/amiNamingConvention.spec.js @@ -0,0 +1,132 @@ +var expect = require('chai').expect; +const amiNamingConvention = require('./amiNamingConvention'); + +const describeImages = [ + { + ImageId: 'ami-046b09f5340dfd8gb', + Tags: [ + { Key: 'Name', Value: 'ami-ue1-p-nodejs' } + ] + }, + { + ImageId: 'ami-046b09f5340dfd8gc', + Tags: [ + { Key: 'Name', Value: 'ami-uw2-d-apache-spark' } + ] + }, + { + ImageId: 'ami-046b09f5340dfd8gd', + Tags: [ + { Key: 'Name', Value: 'MyCustomAMI' } + ] + }, + { + ImageId: 'ami-046b09f5340dfd8ge', + Tags: [ + { Key: 'Environment', Value: 'Production' } + ] + }, + { + ImageId: 'ami-046b09f5340dfd8gf', + Tags: [] + } +]; + +const createCache = (instances) => { + return { + ec2: { + describeImages: { + 'us-east-1': { + data: instances + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeImages: { + 'us-east-1': { + err: { + message: 'error describing AMIs' + } + }, + }, + }, + }; +}; + + +describe('amiNamingConvention', function () { + describe('run', function () { + + it('should return UNKNOWN if unable to query for AMIs', function (done) { + const cache = createErrorCache(); + amiNamingConvention.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for AMIs'); + done(); + }); + }); + + it('should return PASS if no AMIs found', function (done) { + const cache = createCache([]); + amiNamingConvention.run(cache, {}, (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('No AMIs found'); + done(); + }); + }); + + it('should return PASS if AMI Name tag follows naming convention', function (done) { + const cache = createCache([describeImages[0]]); + amiNamingConvention.run(cache, {}, (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('follows organizational naming convention'); + done(); + }); + }); + + it('should return FAIL if AMI Name tag does not follow naming convention', function (done) { + const cache = createCache([describeImages[2]]); + amiNamingConvention.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('does not follow organizational naming convention'); + done(); + }); + }); + + it('should return FAIL if AMI does not have a name tag', function (done) { + const cache = createCache([describeImages[3]]); + amiNamingConvention.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('AMI does not have a name tag'); + done(); + }); + }); + + it('should return FAIL if AMI has empty tags array', function (done) { + const cache = createCache([describeImages[4]]); + amiNamingConvention.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('AMI does not have a name tag'); + done(); + }); + }); + }); +}); + diff --git a/plugins/aws/ec2/ebsRecentSnapshots.js b/plugins/aws/ec2/ebsRecentSnapshots.js index 4e8d9016ca..6f875b5d31 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 { diff --git a/plugins/aws/ec2/ebsRecentSnapshots.spec.js b/plugins/aws/ec2/ebsRecentSnapshots.spec.js index 686316e9b2..0f4bd7dcb8 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/ec2/ec2NetworkExposure.js b/plugins/aws/ec2/ec2NetworkExposure.js index c86c247c24..1892d5c6f6 100644 --- a/plugins/aws/ec2/ec2NetworkExposure.js +++ b/plugins/aws/ec2/ec2NetworkExposure.js @@ -2,7 +2,7 @@ var async = require('async'); var helpers = require('../../../helpers/aws'); module.exports = { - title: 'Network Exposure', + title: 'Internet Exposure', category: 'EC2', domain: 'Compute', severity: 'Info', @@ -10,10 +10,12 @@ module.exports = { more_info: 'EC2 instances exposed to the internet are at a higher risk of unauthorized access, data breaches, and cyberattacks. It’s crucial to limit exposure by securing access through proper configuration of security groups, NACLs, and route tables.', link: 'https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Security.html', recommended_action: 'Secure EC2 instances by restricting access with properly configured security groups and NACLs.', - apis: ['EC2:describeInstances', 'EC2:describeNetworkAcls', 'EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'EC2:describeSubnets', 'EC2:describeRouteTables'], + apis: ['EC2:describeInstances', 'EC2:describeNetworkAcls', 'EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'EC2:describeSubnets', + 'EC2:describeRouteTables', 'ELB:describeLoadBalancers','ELBv2:describeLoadBalancers', 'ELBv2:describeTargetGroups', 'ELBv2:describeTargetHealth', 'ELBv2:describeListeners'], realtime_triggers: ['ec2:RunInstances','ec2:TerminateInstances', 'ec2:CreateNetworkAcl', 'ec2:ReplaceNetworkAclEntry', 'ec2:ReplaceNetworkAclAssociation', 'ec2:DeleteNetworkAcl', 'ec2:CreateSecurityGroup', 'ec2:AuthorizeSecurityGroupIngress','ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', - 'ec2:DeleteSecurityGroup', 'ec2:ModifyInstanceAttribute', 'ec2:ModifySubnetAttribute'], + 'ec2:DeleteSecurityGroup', 'ec2:ModifyInstanceAttribute', 'ec2:ModifySubnetAttribute', 'elasticloadbalancing:CreateLoadBalancer', 'elasticloadbalancing:ModifyTargetGroups', 'elasticloadbalancing:RegisterTarget', 'elasticloadbalancing:DeregisterTargets', 'elasticloadbalancing:DeleteLoadBalancer', + 'elasticloadbalancing:DeleteTargetGroup', 'elasticloadbalancing:RegisterInstancesWithLoadBalancer', 'elasticloadbalancing:DeregisterInstancesWithLoadBalancer','elasticloadbalancing:CreateListener', 'elasticloadbalancing:DeleteListener'], run: function(cache, settings, callback) { var results = []; @@ -44,7 +46,10 @@ module.exports = { const { InstanceId } = instance; const arn = `arn:${awsOrGov}:ec2:${region}:${OwnerId}:instance/${InstanceId}`; - let internetExposed = helpers.checkNetworkExposure(cache, source, instance.SubnetId, instance.SecurityGroups, region, results); + // List all ELB's attached to the instance + let elbs = helpers.getAttachedELBs(cache, source, region, InstanceId, 'Instances', 'InstanceId'); + + let internetExposed = helpers.checkNetworkExposure(cache, source, [{id: instance.SubnetId}], instance.SecurityGroups, elbs, region, results, instance); if (internetExposed && internetExposed.length) { helpers.addResult(results, 2, `EC2 instance is exposed to the internet through ${internetExposed}`, region, arn); } else { @@ -59,3 +64,4 @@ module.exports = { }); } }; + diff --git a/plugins/aws/ec2/ec2PrivilegeAnalysis.js b/plugins/aws/ec2/ec2PrivilegeAnalysis.js new file mode 100644 index 0000000000..997c448e77 --- /dev/null +++ b/plugins/aws/ec2/ec2PrivilegeAnalysis.js @@ -0,0 +1,19 @@ +module.exports = { + title: 'Privilege Analysis', + category: 'EC2', + domain: 'Compute', + severity: 'Info', + description: 'Check if EC2 instances are overly permissive.', + more_info: 'EC2 instances exposed to the internet are at a higher risk of unauthorized access, data breaches, and cyberattacks. It’s crucial to limit exposure by securing access through proper configuration of security groups, NACLs, and route tables.', + link: 'https://docs.aws.amazon.com/vpc/latest/userguide/VPC_Security.html', + recommended_action: 'Secure EC2 instances by restricting access with properly configured security groups and NACLs.', + apis: [], + realtime_triggers: ['ec2:RunInstances','ec2:TerminateInstances'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + callback(null, results, source); + + } +}; diff --git a/plugins/aws/ec2/oldAmi.js b/plugins/aws/ec2/oldAmi.js new file mode 100644 index 0000000000..64df9a9ce1 --- /dev/null +++ b/plugins/aws/ec2/oldAmi.js @@ -0,0 +1,82 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Old Amazon Machine Images', + category: 'EC2', + domain: 'Compute', + severity: 'Low', + description: 'Ensure that Amazon Machine Images (AMIs) are not older than a specified number of days.', + more_info: 'Amazon Machine Images that are too old may contain outdated software, security vulnerabilities, or deprecated configurations. Regularly updating and replacing old AMIs helps maintain security and operational efficiency.', + link: 'https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/AMIs.html', + recommended_action: 'Review and replace AMIs that are older than the specified threshold with newer versions.', + apis: ['EC2:describeImages'], + settings: { + ami_age_fail: { + name: 'AMI Age Fail', + description: 'Return a failing result when AMI exceeds this number of days', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: 90 + } + }, + realtime_triggers: ['ec2:CreateImage', 'ec2:DeregisterImage'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + var awsOrGov = helpers.defaultPartition(settings); + + var config = { + ami_age_fail: parseInt(settings.ami_age_fail || this.settings.ami_age_fail.default), + }; + + async.each(regions.ec2, function(region, rcb){ + var describeImages = helpers.addSource(cache, source, + ['ec2', 'describeImages', region]); + + if (!describeImages) return rcb(); + + if (describeImages.err || !describeImages.data) { + helpers.addResult(results, 3, + 'Unable to query for AMIs: ' + helpers.addError(describeImages), region); + return rcb(); + } + + if (!describeImages.data.length) { + helpers.addResult(results, 0, 'No AMIs found', region); + return rcb(); + } + + var now = new Date(); + + for (var ami of describeImages.data) { + if (!ami.ImageId) continue; + + const arn = 'arn:' + awsOrGov + ':ec2:' + region + '::image/' + ami.ImageId; + + if (!ami.CreationDate) { + helpers.addResult(results, 3, + 'AMI does not have a creation date', region, arn); + continue; + } + + var creationDate = new Date(ami.CreationDate); + var difference = helpers.daysBetween(creationDate, now); + + if (difference > config.ami_age_fail) { + helpers.addResult(results, 2, + `AMI is ${Math.floor(difference)} days old`, region, arn); + } else { + helpers.addResult(results, 0, + `AMI is ${Math.floor(difference)} days old`, region, arn); + } + } + + rcb(); + }, function(){ + callback(null, results, source); + }); + } +}; + diff --git a/plugins/aws/ec2/oldAmi.spec.js b/plugins/aws/ec2/oldAmi.spec.js new file mode 100644 index 0000000000..330191929b --- /dev/null +++ b/plugins/aws/ec2/oldAmi.spec.js @@ -0,0 +1,108 @@ +var expect = require('chai').expect; +const oldAmi = require('./oldAmi'); + +const describeImages = [ + { + ImageId: 'ami-046b09f5340dfd8gb', + CreationDate: new Date(Date.now() - 100 * 24 * 60 * 60 * 1000).toISOString() // 100 days old + }, + { + ImageId: 'ami-046b09f5340dfd8gc', + CreationDate: new Date(Date.now() - 70 * 24 * 60 * 60 * 1000).toISOString() // 70 days old + }, + { + ImageId: 'ami-046b09f5340dfd8gd', + CreationDate: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() // 30 days old + }, + { + ImageId: 'ami-046b09f5340dfd8ge', + // No CreationDate + } +]; + +const createCache = (images) => { + return { + ec2: { + describeImages: { + 'us-east-1': { + data: images + }, + }, + }, + }; +}; + +const createErrorCache = () => { + return { + ec2: { + describeImages: { + 'us-east-1': { + err: { + message: 'error describing AMIs' + } + }, + }, + }, + }; +}; + +describe('oldAmi', function () { + describe('run', function () { + + it('should return UNKNOWN if unable to query for AMIs', function (done) { + const cache = createErrorCache(); + oldAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('Unable to query for AMIs'); + done(); + }); + }); + + it('should return PASS if no AMIs found', function (done) { + const cache = createCache([]); + oldAmi.run(cache, {}, (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('No AMIs found'); + done(); + }); + }); + + it('should return FAIL if AMI is older than fail threshold (90 days)', function (done) { + const cache = createCache([describeImages[0]]); + oldAmi.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('100 days old'); + done(); + }); + }); + + it('should return PASS if AMI is newer than warn threshold', function (done) { + const cache = createCache([describeImages[2]]); + oldAmi.run(cache, {}, (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('30 days old'); + done(); + }); + }); + + it('should return UNKNOWN if AMI does not have a creation date', function (done) { + const cache = createCache([describeImages[3]]); + oldAmi.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].region).to.equal('us-east-1'); + expect(results[0].message).to.include('AMI does not have a creation date'); + done(); + }); + }); + }); +}); + diff --git a/plugins/aws/ec2/securityGroupRfc1918.js b/plugins/aws/ec2/securityGroupRfc1918.js index 12b6eff30a..abeab21515 100644 --- a/plugins/aws/ec2/securityGroupRfc1918.js +++ b/plugins/aws/ec2/securityGroupRfc1918.js @@ -15,7 +15,7 @@ module.exports = { private_cidrs: { name: 'EC2 RFC 1918 CIDR Addresses', description: 'A comma-separated list of CIDRs that indicates reserved private addresses', - regex: '/^(?=.*[^.]$)((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).?){4}$/', + regex: '^(?:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).?){4}/(?:[0-9]|[1-2][0-9]|3[0-2])(?:,(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?).?){4}/(?:[0-9]|[1-2][0-9]|3[0-2]))*)?$', default: '10.0.0.0/8,172.16.0.0/12,192.168.0.0/16' } }, @@ -70,16 +70,15 @@ module.exports = { } } } - - if (!privateCidrsFound.length) { - helpers.addResult(results, 0, - 'Security group "' + group.GroupName + '" is not configured to allow inbound access from any source IP address within any reserved private addresses', - region, resource); - } else { - helpers.addResult(results, 2, - 'Security group "' + group.GroupName + '" is configured to allow inbound access from any source IP address within these reserved private addresses: ' + privateCidrsFound.join(', '), - region, resource); - } + } + if (!privateCidrsFound.length) { + helpers.addResult(results, 0, + 'Security group "' + group.GroupName + '" is not configured to allow inbound access from any source IP address within any reserved private addresses', + region, resource); + } else { + helpers.addResult(results, 2, + 'Security group "' + group.GroupName + '" is configured to allow inbound access from any source IP address within these reserved private addresses: ' + privateCidrsFound.join(', '), + region, resource); } } rcb(); diff --git a/plugins/aws/ecs/ecsFargatePlatformVersion.js b/plugins/aws/ecs/ecsFargatePlatformVersion.js new file mode 100644 index 0000000000..6d73e9bdfc --- /dev/null +++ b/plugins/aws/ecs/ecsFargatePlatformVersion.js @@ -0,0 +1,114 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ECS Fargate Latest Platform Version', + category: 'ECS', + domain: 'Containers', + severity: 'Medium', + description: 'Ensure that Amazon ECS Fargate services are using the latest Fargate platform version.', + more_info: 'Using the latest Fargate platform version ensures services benefit from up-to-date security patches, performance improvements, and feature updates.', + link: 'https://docs.aws.amazon.com/AmazonECS/latest/developerguide/platform_versions.html', + recommended_action: 'Update ECS Fargate services to use the latest platform version.', + apis: ['ECS:listClusters', 'ECS:listServices', 'ECS:describeServices'], + realtime_triggers: ['ecs:CreateCluster', 'ecs:CreateService', 'ecs:UpdateService', 'ecs:DeleteService', 'ecs:DeleteCluster'], + run: function(cache, settings, callback){ + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.ecs, function(region, rcb){ + + var listClusters = helpers.addSource(cache, source, + ['ecs', 'listClusters', region]); + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for ECS clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (!listClusters.data.length) { + helpers.addResult(results, 0, 'No ECS clusters found', region); + return rcb(); + } + + for (var clusterArn of listClusters.data) { + var listServices = helpers.addSource(cache, source, + ['ecs', 'listServices', region, clusterArn]); + + if (!listServices || listServices.err || !listServices.data) { + helpers.addResult(results, 3, + 'Unable to query for ECS services: ' + helpers.addError(listServices), region, clusterArn); + continue; + } + + if (!listServices.data.length) { + helpers.addResult(results, 0, + 'No ECS Fargate services found in cluster', region, clusterArn); + continue; + } + + var hasFargateServices = false; + + for (var serviceArn of listServices.data) { + var describeServices = helpers.addSource(cache, source, + ['ecs', 'describeServices', region, serviceArn]); + + if (!describeServices || describeServices.err || !describeServices.data) { + helpers.addResult(results, 3, + 'Unable to describe ECS service: ' + helpers.addError(describeServices), region, serviceArn); + continue; + } + + var service = null; + if (describeServices.data.services && describeServices.data.services.length > 0) { + service = describeServices.data.services[0]; + } + if (!service) continue; + + var isFargate = false; + + if (service.launchType && service.launchType.toLowerCase() === 'fargate') { + isFargate = true; + } else if (service.capacityProviderStrategy && service.capacityProviderStrategy.length > 0) { + for (var cp of service.capacityProviderStrategy) { + if (cp.capacityProvider && cp.capacityProvider.toLowerCase().indexOf('fargate') !== -1) { + isFargate = true; + break; + } + } + } + + if (!isFargate) continue; + + hasFargateServices = true; + var platformVersion = service.platformVersion; + var platformVersionLower = platformVersion ? platformVersion.toLowerCase() : ''; + + if (platformVersionLower !== 'latest') { + helpers.addResult(results, 2, + 'ECS Fargate service is not using the latest platform version', + region, serviceArn); + } else { + helpers.addResult(results, 0, + 'ECS Fargate service is using the latest platform version', + region, serviceArn); + } + } + + if (!hasFargateServices) { + helpers.addResult(results, 0, + 'No ECS Fargate services found in cluster', + region, clusterArn); + } + } + rcb(); + }, + function(){ + callback(null, results, source); + }); + } +}; + diff --git a/plugins/aws/ecs/ecsFargatePlatformVersion.spec.js b/plugins/aws/ecs/ecsFargatePlatformVersion.spec.js new file mode 100644 index 0000000000..fb97e625a7 --- /dev/null +++ b/plugins/aws/ecs/ecsFargatePlatformVersion.spec.js @@ -0,0 +1,215 @@ +var expect = require('chai').expect; +const ecsFargatePlatformVersion = require('./ecsFargatePlatformVersion'); + +const listClusters = [ + 'arn:aws:ecs:us-east-1:112233445566:cluster/test-cluster', + 'arn:aws:ecs:us-east-1:112233445566:cluster/another-cluster' +]; + +const createCache = (clusters, servicesMap, describeServicesMap) => { + var cache = { + ecs: { + listClusters: { + 'us-east-1': { + data: clusters || [] + } + }, + listServices: { + 'us-east-1': {} + }, + describeServices: { + 'us-east-1': {} + } + } + }; + + if (clusters && clusters.length) { + for (var clusterArn of clusters) { + if (servicesMap && servicesMap[clusterArn]) { + cache.ecs.listServices['us-east-1'][clusterArn] = { + data: servicesMap[clusterArn] + }; + } else { + cache.ecs.listServices['us-east-1'][clusterArn] = { + data: [] + }; + } + } + } + + if (describeServicesMap) { + for (var serviceArn in describeServicesMap) { + cache.ecs.describeServices['us-east-1'][serviceArn] = { + data: describeServicesMap[serviceArn] + }; + } + } + + return cache; +}; + +const createErrorCache = () => { + return { + ecs: { + listClusters: { + 'us-east-1': { + err: { + message: 'error listing clusters' + } + } + } + } + }; +}; + +describe('ecsFargatePlatformVersion', function () { + describe('run', function () { + it('should PASS if no clusters found', function (done) { + const cache = createCache([], {}, {}); + ecsFargatePlatformVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ECS clusters found'); + done(); + }); + }); + + it('should PASS if no Fargate services found', function (done) { + const servicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:cluster/test-cluster': [ + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service' + ] + }; + const describeServicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service': { + services: [{ + serviceName: 'my-service', + serviceArn: 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service', + launchType: 'EC2' + }] + } + }; + const cache = createCache([listClusters[0]], servicesMap, describeServicesMap); + ecsFargatePlatformVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ECS Fargate services found'); + done(); + }); + }); + + it('should PASS if Linux Fargate service uses LATEST platform version', function (done) { + const servicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:cluster/test-cluster': [ + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service' + ] + }; + const describeServicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service': { + services: [{ + serviceName: 'my-service', + serviceArn: 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service', + launchType: 'FARGATE', + platformVersion: 'LATEST', + platformFamily: 'LINUX' + }] + } + }; + const cache = createCache([listClusters[0]], servicesMap, describeServicesMap); + ecsFargatePlatformVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('using the latest platform version'); + done(); + }); + }); + + it('should FAIL if Linux Fargate service uses 1.3.0 platform version', function (done) { + const servicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:cluster/test-cluster': [ + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service' + ] + }; + const describeServicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service': { + services: [{ + serviceName: 'my-service', + serviceArn: 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service', + launchType: 'FARGATE', + platformVersion: '1.3.0', + platformFamily: 'LINUX' + }] + } + }; + const cache = createCache([listClusters[0]], servicesMap, describeServicesMap); + ecsFargatePlatformVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not using the latest platform version'); + done(); + }); + }); + + it('should PASS if Windows Fargate service uses LATEST platform version', function (done) { + const servicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:cluster/test-cluster': [ + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service' + ] + }; + const describeServicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service': { + services: [{ + serviceName: 'my-service', + serviceArn: 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service', + launchType: 'FARGATE', + platformVersion: 'LATEST', + platformFamily: 'WINDOWS_SERVER' + }] + } + }; + const cache = createCache([listClusters[0]], servicesMap, describeServicesMap); + ecsFargatePlatformVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('using the latest platform version'); + done(); + }); + }); + + it('should FAIL if Fargate service has no platform version configured', function (done) { + const servicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:cluster/test-cluster': [ + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service' + ] + }; + const describeServicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service': { + services: [{ + serviceName: 'my-service', + serviceArn: 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service', + launchType: 'FARGATE', + platformFamily: 'LINUX' + }] + } + }; + const cache = createCache([listClusters[0]], servicesMap, describeServicesMap); + ecsFargatePlatformVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('is not using the latest platform version'); + done(); + }); + }); + + it('should UNKNOWN if unable to list clusters', function (done) { + const cache = createErrorCache(); + ecsFargatePlatformVersion.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query'); + done(); + }); + }); + }); +}); + diff --git a/plugins/aws/ecs/ecsServicePublicIpDisabled.js b/plugins/aws/ecs/ecsServicePublicIpDisabled.js new file mode 100644 index 0000000000..e3f65eb3a2 --- /dev/null +++ b/plugins/aws/ecs/ecsServicePublicIpDisabled.js @@ -0,0 +1,96 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'ECS Service Public IP Disabled', + category: 'ECS', + domain: 'Containers', + severity: 'High', + description: 'Ensure that Amazon ECS services have assignPublicIp set to disabled.', + more_info: 'Enabling public IP assignment could expose container application servers to unintended or unauthorized access. Services should use private networking with NAT gateways or VPC endpoints for outbound internet access.', + link: 'https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-networking.html', + recommended_action: 'Modify ECS services to set assignPublicIp to disabled in the network configuration.', + apis: ['ECS:listClusters', 'ECS:listServices', 'ECS:describeServices'], + realtime_triggers: ['ecs:CreateService', 'ecs:UpdateService', 'ecs:DeleteService', 'ecs:CreateCluster', 'ecs:DeleteCluster', 'ecs:UpdateCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.ecs, function(region, rcb) { + var listClusters = helpers.addSource(cache, source, + ['ecs', 'listClusters', region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for ECS clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (!listClusters.data.length) { + helpers.addResult(results, 0, 'No ECS clusters found', region); + return rcb(); + } + + for (var clusterArn of listClusters.data) { + var listServices = helpers.addSource(cache, source, + ['ecs', 'listServices', region, clusterArn]); + + if (!listServices || listServices.err || !listServices.data) { + helpers.addResult(results, 3, + 'Unable to query for ECS services: ' + helpers.addError(listServices), region, clusterArn); + continue; + } + + if (!listServices.data.length) { + helpers.addResult(results, 0, + 'No ECS services found in cluster', region, clusterArn); + continue; + } + + for (var serviceArn of listServices.data) { + var describeServices = helpers.addSource(cache, source, + ['ecs', 'describeServices', region, serviceArn]); + + if (!describeServices || describeServices.err || !describeServices.data) { + helpers.addResult(results, 3, + 'Unable to describe ECS service: ' + helpers.addError(describeServices), region, serviceArn); + continue; + } + + + var service = null; + if (describeServices.data.services && describeServices.data.services.length > 0) { + service = describeServices.data.services[0]; + } + if (!service) continue; + + var networkMode = service.networkConfiguration; + var assignPublicIp = null; + + if (networkMode && networkMode.awsvpcConfiguration) { + assignPublicIp = networkMode.awsvpcConfiguration.assignPublicIp; + var assignPublicIpLower = assignPublicIp ? assignPublicIp.toLowerCase() : ''; + if (assignPublicIpLower !== 'disabled') { + helpers.addResult(results, 2, + 'ECS service does not have assignPublicIp set to disabled', + region, serviceArn); + } else { + helpers.addResult(results, 0, + 'ECS service has assignPublicIp set to disabled', + region, serviceArn); + } + } + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + + diff --git a/plugins/aws/ecs/ecsServicePublicIpDisabled.spec.js b/plugins/aws/ecs/ecsServicePublicIpDisabled.spec.js new file mode 100644 index 0000000000..3bbaeb4d80 --- /dev/null +++ b/plugins/aws/ecs/ecsServicePublicIpDisabled.spec.js @@ -0,0 +1,158 @@ +var expect = require('chai').expect; +const ecsServicePublicIpDisabled = require('./ecsServicePublicIpDisabled'); + +const listClusters = [ + 'arn:aws:ecs:us-east-1:112233445566:cluster/test-cluster', + 'arn:aws:ecs:us-east-1:112233445566:cluster/another-cluster' +]; + +const createCache = (clusters, servicesMap, describeServicesMap) => { + var cache = { + ecs: { + listClusters: { + 'us-east-1': { + data: clusters || [] + } + }, + listServices: { + 'us-east-1': {} + }, + describeServices: { + 'us-east-1': {} + } + } + }; + + if (clusters && clusters.length) { + for (var clusterArn of clusters) { + if (servicesMap && servicesMap[clusterArn]) { + cache.ecs.listServices['us-east-1'][clusterArn] = { + data: servicesMap[clusterArn] + }; + } else { + cache.ecs.listServices['us-east-1'][clusterArn] = { + data: [] + }; + } + } + } + + if (describeServicesMap) { + for (var serviceArn in describeServicesMap) { + cache.ecs.describeServices['us-east-1'][serviceArn] = { + data: describeServicesMap[serviceArn] + }; + } + } + + return cache; +}; + +const createErrorCache = () => { + return { + ecs: { + listClusters: { + 'us-east-1': { + err: { + message: 'error listing clusters' + } + } + } + } + }; +}; + +describe('ecsServicePublicIpDisabled', function () { + describe('run', function () { + it('should PASS if no clusters found', function (done) { + const cache = createCache([], {}, {}); + ecsServicePublicIpDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ECS clusters found'); + done(); + }); + }); + + it('should PASS if no services found', function (done) { + const cache = createCache(listClusters, {}, {}); + ecsServicePublicIpDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No ECS services found in cluster'); + done(); + }); + }); + + it('should PASS if service has assignPublicIp set to disabled', function (done) { + const servicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:cluster/test-cluster': [ + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service' + ] + }; + const describeServicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service': { + services: [{ + serviceName: 'my-service', + serviceArn: 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service', + networkConfiguration: { + awsvpcConfiguration: { + assignPublicIp: 'DISABLED', + subnets: ['subnet-12345'], + securityGroups: ['sg-12345'] + } + } + }] + } + }; + const cache = createCache([listClusters[0]], servicesMap, describeServicesMap); + ecsServicePublicIpDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('has assignPublicIp set to disabled'); + done(); + }); + }); + + it('should FAIL if service has assignPublicIp set to ENABLED', function (done) { + const servicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:cluster/test-cluster': [ + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service' + ] + }; + const describeServicesMap = { + 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service': { + services: [{ + serviceName: 'my-service', + serviceArn: 'arn:aws:ecs:us-east-1:112233445566:service/test-cluster/my-service', + networkConfiguration: { + awsvpcConfiguration: { + assignPublicIp: 'ENABLED', + subnets: ['subnet-12345'], + securityGroups: ['sg-12345'] + } + } + }] + } + }; + const cache = createCache([listClusters[0]], servicesMap, describeServicesMap); + ecsServicePublicIpDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have assignPublicIp set to disabled'); + done(); + }); + }); + + it('should UNKNOWN if unable to list clusters', function (done) { + const cache = createErrorCache(); + ecsServicePublicIpDisabled.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query'); + done(); + }); + }); + }); +}); + diff --git a/plugins/aws/eks/eksKubernetesVersion.js b/plugins/aws/eks/eksKubernetesVersion.js index 478e4993a1..26d362c82b 100644 --- a/plugins/aws/eks/eksKubernetesVersion.js +++ b/plugins/aws/eks/eksKubernetesVersion.js @@ -42,7 +42,10 @@ module.exports = { '1.26': '2024-06-11', '1.27': '2024-07-24', '1.28': '2024-11-26', - '1.29': '2025-03-23' + '1.29': '2025-03-23', + '1.30': '2025-07-23', + '1.31': '2025-11-26', + '1.32': '2026-03-23' }; var outdatedVersions = { diff --git a/plugins/aws/eks/eksKubernetesVersion.spec.js b/plugins/aws/eks/eksKubernetesVersion.spec.js index 0997f85358..f2718d3a5c 100644 --- a/plugins/aws/eks/eksKubernetesVersion.spec.js +++ b/plugins/aws/eks/eksKubernetesVersion.spec.js @@ -82,7 +82,7 @@ describe('eksKubernetesVersion', function () { "cluster": { "name": "mycluster", "arn": "arn:aws:eks:us-east-1:012345678911:cluster/mycluster", - "version": "1.29", + "version": "1.31", } } ); diff --git a/plugins/aws/eks/eksNetworkExposure.js b/plugins/aws/eks/eksNetworkExposure.js new file mode 100644 index 0000000000..d2306e27a9 --- /dev/null +++ b/plugins/aws/eks/eksNetworkExposure.js @@ -0,0 +1,88 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Internet Exposure', + category: 'EKS', + domain: 'Containers', + severity: 'Info', + description: 'Check if EKS clusters are exposed to the internet.', + more_info: 'EKS clusters exposed to the internet are vulnerable to unauthorized access, potential data loss, and increased cyberattack risks. Securing access to the EKS API server, worker nodes, and associated resources by configuring security groups, NACLs, and using private subnets is essential for minimizing exposure.', + link: 'https://docs.aws.amazon.com/eks/latest/userguide/network_reqs.html', + recommended_action: 'Restrict public access to the EKS API server and worker nodes by ensuring proper configuration of API endpoint access, security groups, and NACLs. Utilize private subnets and NAT gateways where appropriate for worker node traffic.', + apis: ['EKS:listClusters', 'EKS:describeCluster', 'STS:getCallerIdentity', 'EC2:describeSecurityGroups', 'EC2:describeNetworkInterfaces', 'EC2:describeSubnets', + 'EC2:describeRouteTables'], + realtime_triggers: ['eks:CreateCluster', 'eks:updateClusterConfig', 'eks:DeleteCluster','ec2:CreateNetworkAcl', 'ec2:ReplaceNetworkAclEntry', 'ec2:ReplaceNetworkAclAssociation', + 'ec2:DeleteNetworkAcl', 'ec2:CreateSecurityGroup', 'ec2:AuthorizeSecurityGroupIngress','ec2:ModifySecurityGroupRules','ec2:RevokeSecurityGroupIngress', + 'ec2:DeleteSecurityGroup'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + var acctRegion = helpers.defaultRegion(settings); + var awsOrGov = helpers.defaultPartition(settings); + var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion, 'data']); + + async.each(regions.eks, function(region, rcb) { + var listClusters = helpers.addSource(cache, source, + ['eks', 'listClusters', region]); + + if (!listClusters) return rcb(); + + if (listClusters.err || !listClusters.data) { + helpers.addResult( + results, 3, + 'Unable to query for EKS clusters: ' + helpers.addError(listClusters), region); + return rcb(); + } + + if (listClusters.data.length === 0){ + helpers.addResult(results, 0, 'No EKS clusters present', region); + return rcb(); + } + + for (var c in listClusters.data) { + var clusterName = listClusters.data[c]; + var describeCluster = helpers.addSource(cache, source, + ['eks', 'describeCluster', region, clusterName]); + + var arn = 'arn:' + awsOrGov + ':eks:' + region + ':' + accountId + ':cluster/' + clusterName; + + if (!describeCluster || describeCluster.err || !describeCluster.data) { + helpers.addResult( + results, 3, + 'Unable to describe EKS cluster: ' + helpers.addError(describeCluster), + region, arn); + continue; + } + + describeCluster.data.arn = arn; + + if (describeCluster.data.cluster) { + let cluster = describeCluster.data.cluster; + let securityGroups = []; + if (cluster.resourcesVpcConfig) { + if (cluster.resourcesVpcConfig.clusterSecurityGroupId) { + securityGroups.push(cluster.resourcesVpcConfig.clusterSecurityGroupId); + } + if (cluster.resourcesVpcConfig.securityGroupIds) { + securityGroups = securityGroups.concat(cluster.resourcesVpcConfig.securityGroupIds); + } + let internetExposed = helpers.checkNetworkExposure(cache, source, cluster.resourcesVpcConfig.subnetIds, securityGroups, [], region, results, cluster); + if (internetExposed && internetExposed.length) { + helpers.addResult(results, 2, `EKS cluster is exposed to the internet through ${internetExposed}`, region, arn); + } else { + helpers.addResult(results, 0, 'EKS cluster is not exposed to the internet', region, arn); + } + } + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/eks/eksPrivilegeAnalysis.js b/plugins/aws/eks/eksPrivilegeAnalysis.js new file mode 100644 index 0000000000..cae32738d9 --- /dev/null +++ b/plugins/aws/eks/eksPrivilegeAnalysis.js @@ -0,0 +1,20 @@ + +module.exports = { + title: 'Privilege Analysis', + category: 'EKS', + domain: 'Containers', + severity: 'Info', + description: 'Ensures no EKS cluster available in your AWS account is overly-permissive.', + more_info: 'Overly-permissive EKS clusters can expose your infrastructure to unauthorized access or accidental misconfigurations. Regular analysis of permissions helps maintain a secure cluster setup.', + link: 'https://docs.aws.amazon.com/eks/latest/userguide/cluster-endpoint.html', + recommended_action: 'Audit the IAM roles and policies associated with your EKS cluster. Restrict access to the minimum necessary permissions and configure security groups and endpoint access control appropriately.', + apis: [''], + realtime_triggers: ['eks:CreateCluster', 'eks:updateClusterConfig', 'eks:DeleteCluster'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + callback(null, results, source); + } +}; diff --git a/plugins/aws/guardduty/eksProtectionEnabled.js b/plugins/aws/guardduty/eksProtectionEnabled.js index 05f7daa5e2..befb261da5 100644 --- a/plugins/aws/guardduty/eksProtectionEnabled.js +++ b/plugins/aws/guardduty/eksProtectionEnabled.js @@ -12,7 +12,7 @@ module.exports = { link: 'https://docs.aws.amazon.com/guardduty/latest/ug/kubernetes-protection.html', apis: ['GuardDuty:listDetectors', 'GuardDuty:getDetector', 'STS:getCallerIdentity'], realtime_triggers: ['guardduty:CreateDetector', 'guardduty:UpdateDetector', 'guardduty:DeleteDetector'], - + run: function(cache, settings, callback) { var results = []; var source = {}; @@ -39,7 +39,7 @@ module.exports = { } listDetectors.data.forEach(function(detectorId) { - + var resource = 'arn:' + awsOrGov + ':guardduty:' + region + ':' + accountId + ':detector/' + detectorId; var getDetector = helpers.addSource(cache, source, ['guardduty', 'getDetector', region, detectorId]); if (!getDetector) return; @@ -50,20 +50,19 @@ module.exports = { } var detector = getDetector.data; - var resource = 'arn:' + awsOrGov + ':guardduty:' + region + ':' + accountId + ':detector/' + detector.detectorId; - if (detector.DataSources && - detector.DataSources.Kubernetes && - detector.DataSources.Kubernetes.AuditLogs && + if (detector.DataSources && + detector.DataSources.Kubernetes && + detector.DataSources.Kubernetes.AuditLogs && detector.DataSources.Kubernetes.AuditLogs.Status && detector.DataSources.Kubernetes.AuditLogs.Status.toLowerCase() === 'disabled'){ helpers.addResult(results, 2, 'GuardDuty EKS protection is disabled', region, resource); } else { helpers.addResult(results, 0, 'GuardDuty EKS protection is enabled', region, resource); - } - + } + }); - + rcb(); }, function(){ callback(null, results, source); diff --git a/plugins/aws/guardduty/lambdaProtectionEnabled.js b/plugins/aws/guardduty/lambdaProtectionEnabled.js index 7e960bc4c8..9d29735e20 100644 --- a/plugins/aws/guardduty/lambdaProtectionEnabled.js +++ b/plugins/aws/guardduty/lambdaProtectionEnabled.js @@ -39,7 +39,7 @@ module.exports = { } listDetectors.data.forEach(function(detectorId) { - + var resource = `arn:${awsOrGov}:guardduty:${region}:${accountId}:detector/${detectorId}`; var getDetector = helpers.addSource(cache, source, ['guardduty', 'getDetector', region, detectorId]); if (!getDetector) return; @@ -50,7 +50,6 @@ module.exports = { } var detector = getDetector.data; - var resource = `arn:${awsOrGov}:guardduty:${region}:${accountId}:detector/${detector.detectorId}`; var lambdaLoginEventsFeature = (detector.Features && detector.Features.find(feature => feature.Name === 'LAMBDA_NETWORK_LOGS' && feature.Status === 'ENABLED')) ? true : false; if (lambdaLoginEventsFeature) { diff --git a/plugins/aws/guardduty/noActiveFindings.spec.js b/plugins/aws/guardduty/noActiveFindings.spec.js index 9ada2f7ca9..ef01aab0de 100644 --- a/plugins/aws/guardduty/noActiveFindings.spec.js +++ b/plugins/aws/guardduty/noActiveFindings.spec.js @@ -55,8 +55,8 @@ const getFindings = [ "Region": "us-east-1", "Resource": { "AccessKeyDetails": { - "AccessKeyId": "ASIARPGOCGXS5IZZUZNG", - "PrincipalId": "AIDARPGOCGXSQEYQAT545", + "AccessKeyId": "ABCDEFGHI", + "PrincipalId": "000011112222", "UserName": "sadeed", "UserType": "IAMUser" }, @@ -118,7 +118,7 @@ const getFindings = [ "Region": "us-east-1", "Resource": { "AccessKeyDetails": { - "AccessKeyId": "ASIARPGOCGXSYK2EKS4B", + "AccessKeyId": "ABCDEFGHI", "PrincipalId": "000011112222", "UserName": "aws-viteace", "UserType": "Root" @@ -263,4 +263,4 @@ describe('noActiveFindings', function () { }); }); }); -}); \ No newline at end of file +}); diff --git a/plugins/aws/guardduty/rdsProtectionEnabled.js b/plugins/aws/guardduty/rdsProtectionEnabled.js index 221baed889..7a462e897c 100644 --- a/plugins/aws/guardduty/rdsProtectionEnabled.js +++ b/plugins/aws/guardduty/rdsProtectionEnabled.js @@ -39,7 +39,7 @@ module.exports = { } listDetectors.data.forEach(function(detectorId) { - + var resource = `arn:${awsOrGov}:guardduty:${region}:${accountId}:detector/${detectorId}`; var getDetector = helpers.addSource(cache, source, ['guardduty', 'getDetector', region, detectorId]); if (!getDetector) return; @@ -50,7 +50,6 @@ module.exports = { } var detector = getDetector.data; - var resource = `arn:${awsOrGov}:guardduty:${region}:${accountId}:detector/${detector.detectorId}`; var rdsLoginEventsFeature = (detector.Features && detector.Features.find(feature => feature.Name === 'RDS_LOGIN_EVENTS' && feature.Status === 'ENABLED')) ? true : false; if (rdsLoginEventsFeature) { diff --git a/plugins/aws/guardduty/s3ProtectionEnabled.js b/plugins/aws/guardduty/s3ProtectionEnabled.js index aa952cc7ea..a1002c2c73 100644 --- a/plugins/aws/guardduty/s3ProtectionEnabled.js +++ b/plugins/aws/guardduty/s3ProtectionEnabled.js @@ -39,7 +39,7 @@ module.exports = { } listDetectors.data.forEach(function(detectorId) { - + var resource = `arn:${awsOrGov}:guardduty:${region}:${accountId}:detector/${detectorId}`; var getDetector = helpers.addSource(cache, source, ['guardduty', 'getDetector', region, detectorId]); if (!getDetector) return; @@ -50,7 +50,6 @@ module.exports = { } var detector = getDetector.data; - var resource = 'arn:' + awsOrGov + ':guardduty:' + region + ':' + accountId + ':detector/' + detector.detectorId; if ( detector.DataSources && detector.DataSources.S3Logs && detector.DataSources.S3Logs.Status === 'DISABLED'){ helpers.addResult(results, 2, 'GuardDuty S3 protection is disabled', region, resource); diff --git a/plugins/aws/iam/iamRolePolicies.js b/plugins/aws/iam/iamRolePolicies.js index 6d3dd14d23..9180eeb59d 100644 --- a/plugins/aws/iam/iamRolePolicies.js +++ b/plugins/aws/iam/iamRolePolicies.js @@ -60,7 +60,7 @@ module.exports = { }, ignore_customer_managed_iam_policies: { name: 'Ignore Customer-Managed IAM Policies', - description: 'If set to true, skip customer-managed policies attached to the role', + description: 'If set to true, skip customer-managed policies attached to the role.', regex: '^(true|false)$', default: 'false' }, @@ -81,6 +81,12 @@ module.exports = { description: 'Enable this setting to ignore resource wildcards i.e. \'"Resource": "*"\' in the IAM policy, which by default, are being flagged.', regex: '^(true|false)$', default: 'false' + }, + iam_policy_message_format: { + name: 'IAM Policy Message Format', + description: 'Enable this setting to include policy names in the failure messages', + regex: '^(true|false)$', + default: 'false' } }, realtime_triggers: ['iam:CreateRole','iam:DeleteRole','iam:AttachRolePolicy','iam:DetachRolePolicy','iam:PutRolePolicy','iam:DeleteRolePolicy'], @@ -94,7 +100,8 @@ module.exports = { ignore_customer_managed_iam_policies: settings.ignore_customer_managed_iam_policies || this.settings.ignore_customer_managed_iam_policies.default, iam_role_policies_ignore_tag: settings.iam_role_policies_ignore_tag || this.settings.iam_role_policies_ignore_tag.default, iam_policy_resource_specific_wildcards: settings.iam_policy_resource_specific_wildcards || this.settings.iam_policy_resource_specific_wildcards.default, - ignore_iam_policy_resource_wildcards: settings.ignore_iam_policy_resource_wildcards || this.settings.ignore_iam_policy_resource_wildcards.default + ignore_iam_policy_resource_wildcards: settings.ignore_iam_policy_resource_wildcards || this.settings.ignore_iam_policy_resource_wildcards.default, + iam_policy_message_format: settings.iam_policy_message_format || this.settings.iam_policy_message_format.default }; config.ignore_service_specific_wildcards = (config.ignore_service_specific_wildcards === 'true'); @@ -102,7 +109,7 @@ module.exports = { config.ignore_aws_managed_iam_policies = (config.ignore_aws_managed_iam_policies === 'true'); config.ignore_customer_managed_iam_policies = (config.ignore_customer_managed_iam_policies === 'true'); config.ignore_iam_policy_resource_wildcards = (config.ignore_iam_policy_resource_wildcards === 'true'); - + config.iam_policy_message_format = (config.iam_policy_message_format === 'true'); var allowedRegex = RegExp(config.iam_policy_resource_specific_wildcards); @@ -196,7 +203,8 @@ module.exports = { return cb(); } - var roleFailures = []; + var roleFailures = config.iam_policy_message_format ? {} : []; + // See if role has admin managed policy if (listAttachedRolePolicies.data && @@ -204,7 +212,11 @@ module.exports = { for (var policy of listAttachedRolePolicies.data.AttachedPolicies) { if (policy.PolicyArn === managedAdminPolicy) { - roleFailures.push('Role has managed AdministratorAccess policy'); + if (config.iam_policy_message_format) { + roleFailures.admin = 'managedAdminPolicy'; + } else { + roleFailures.push('Role has managed AdministratorAccess policy'); + } break; } @@ -230,7 +242,11 @@ module.exports = { getPolicyVersion.data.PolicyVersion.Document); if (!statements) break; - addRoleFailures(roleFailures, statements, 'managed', config.ignore_service_specific_wildcards, allowedRegex, config.ignore_iam_policy_resource_wildcards); + if (config.iam_policy_message_format) { + addRoleFailuresPolicyName(roleFailures, statements, 'managed', policy.PolicyName, config.ignore_service_specific_wildcards, allowedRegex, config.ignore_iam_policy_resource_wildcards); + } else { + addRoleFailures(roleFailures, statements, 'managed', config.ignore_service_specific_wildcards, allowedRegex, config.ignore_iam_policy_resource_wildcards); + } } } } @@ -249,21 +265,22 @@ module.exports = { var statements = getRolePolicy[policyName].data.PolicyDocument; if (!statements) break; - addRoleFailures(roleFailures, statements, 'inline', config.ignore_service_specific_wildcards, allowedRegex, config.ignore_iam_policy_resource_wildcards); + if (config.iam_policy_message_format) { + addRoleFailuresPolicyName(roleFailures, statements, 'inline', policyName, config.ignore_service_specific_wildcards, allowedRegex, config.ignore_iam_policy_resource_wildcards); + } else { + addRoleFailures(roleFailures, statements, 'inline', config.ignore_service_specific_wildcards, allowedRegex, config.ignore_iam_policy_resource_wildcards); + } } } } - if (roleFailures.length) { - helpers.addResult(results, 2, - roleFailures.join(', '), - 'global', role.Arn, custom); + if (config.iam_policy_message_format) { + compileFormattedResults(roleFailures, role, results, custom); } else { - helpers.addResult(results, 0, - 'Role does not have overly-permissive policy', - 'global', role.Arn, custom); + compileSimpleResults(roleFailures, role, results, custom); } + cb(); }, function(){ callback(null, results, source); @@ -308,4 +325,173 @@ function addRoleFailures(roleFailures, statements, policyType, ignoreServiceSpec if (failMsg && roleFailures.indexOf(failMsg) === -1) roleFailures.push(failMsg); } } +} + +function addRoleFailuresPolicyName(roleFailures, statements, policyType, policyName, ignoreServiceSpecific, regResource, ignoreResourceSpecific) { + // Initialize roleFailures as an object for the first time + if (!roleFailures.managed) { + roleFailures.managed = { + allActionsAllResources: [], + allActionsSelectedResources: [], + actionsAllResources: [], + wildcardActions: {}, + regexMismatch: {} + }; + } + if (!roleFailures.inline) { + roleFailures.inline = { + allActionsAllResources: [], + allActionsSelectedResources: [], + actionsAllResources: [], + wildcardActions: {}, + regexMismatch: {} + }; + } + if (!roleFailures.admin) roleFailures.admin = false; + + for (var statement of statements) { + if (statement.Effect === 'Allow' && !statement.Condition) { + let targetObj = roleFailures[policyType]; + + if (statement.Action && + statement.Action.indexOf('*') > -1 && + statement.Resource && + statement.Resource.indexOf('*') > -1) { + targetObj.allActionsAllResources.push(policyName); + } else if (statement.Action.indexOf('*') > -1) { + targetObj.allActionsSelectedResources.push(policyName); + } else if (!ignoreResourceSpecific && statement.Resource && statement.Resource == '*') { + targetObj.actionsAllResources.push(policyName); + } else if (!ignoreServiceSpecific && statement.Action && statement.Action.length) { + // Check each action for wildcards + let wildcards = []; + for (var a in statement.Action) { + if (/^.+:[a-zA-Z]?\*.?$/.test(statement.Action[a])) { + wildcards.push(statement.Action[a]); + } + } + if (wildcards.length) { + if (!targetObj.wildcardActions[wildcards.join(', ')]) { + targetObj.wildcardActions[wildcards.join(', ')] = []; + } + if (!targetObj.wildcardActions[wildcards.join(', ')].includes(policyName)) { + targetObj.wildcardActions[wildcards.join(', ')].push(policyName); + } + } + } else if (statement.Resource && statement.Resource.length) { + // Check each resource for wildcard + let wildcards = []; + for (var resource of statement.Resource) { + if (!regResource.test(resource)) { + wildcards.push(resource); + } + } + if (wildcards.length) { + if (!targetObj.regexMismatch[wildcards.join(', ')]) { + targetObj.regexMismatch[wildcards.join(', ')] = []; + } + if (!targetObj.regexMismatch[wildcards.join(', ')].includes(policyName)) { + targetObj.regexMismatch[wildcards.join(', ')].push(policyName); + } + } + } + } + } +} + +function hasFailures(roleFailures) { + if (roleFailures.admin) return true; + + if (roleFailures.managed) { + if (roleFailures.managed.allActionsAllResources.length) return true; + if (roleFailures.managed.allActionsSelectedResources.length) return true; + if (roleFailures.managed.actionsAllResources.length) return true; + if (Object.keys(roleFailures.managed.wildcardActions).length) return true; + if (roleFailures.managed.regexMismatch.length) return true; + } + + if (roleFailures.inline) { + if (roleFailures.inline.allActionsAllResources.length) return true; + if (roleFailures.inline.allActionsSelectedResources.length) return true; + if (roleFailures.inline.actionsAllResources.length) return true; + if (Object.keys(roleFailures.inline.wildcardActions).length) return true; + if (roleFailures.inline.regexMismatch.length) return true; + } + + return false; +} + +function formatPolicyNames(policyArray) { + if (policyArray.length <= 5) { + return [...new Set(policyArray)].join('", "'); + } + return [...new Set(policyArray)].slice(0, 5).join('", "') + '" and so on...'; +} + +function compileSimpleResults(roleFailures, role, results, custom) { + if (roleFailures.length) { + helpers.addResult(results, 2, + roleFailures.join(', '), + 'global', role.Arn, custom); + } else { + helpers.addResult(results, 0, + 'Role does not have overly-permissive policy', + 'global', role.Arn, custom); + } +} + +function compileFormattedResults(roleFailures, role, results, custom) { + if (hasFailures(roleFailures)) { + let messages = []; + + if (roleFailures.admin == 'managedAdminPolicy') { + messages.push('Role has managed AdministratorAccess policy'); + } + + // Format managed policies + if (roleFailures.managed) { + if (roleFailures.managed.allActionsAllResources.length) { + messages.push(`Role managed policy "${formatPolicyNames(roleFailures.managed.allActionsAllResources)}" allows all actions on all resources`); + } + if (roleFailures.managed.allActionsSelectedResources.length) { + messages.push(`Role managed policy "${formatPolicyNames(roleFailures.managed.allActionsSelectedResources)}" allows all actions on selected resources`); + } + if (roleFailures.managed.actionsAllResources.length) { + messages.push(`Role managed policy "${formatPolicyNames(roleFailures.managed.actionsAllResources)}" allows actions on all resources`); + } + for (let action in roleFailures.managed.wildcardActions) { + messages.push(`Role managed policy "${roleFailures.managed.wildcardActions[action].join('", "')}" allows wildcard actions: ${action}`); + } + for (let resource in roleFailures.managed.regexMismatch) { + messages.push(`Role managed policy "${roleFailures.managed.regexMismatch[resource].join('", "')}" does not match provided regex: ${resource}`); + } + } + + // Format inline policies + if (roleFailures.inline) { + if (roleFailures.inline.allActionsAllResources.length) { + messages.push(`Role inline policy "${formatPolicyNames(roleFailures.inline.allActionsAllResources)}" allows all actions on all resources`); + } + if (roleFailures.inline.allActionsSelectedResources.length) { + messages.push(`Role inline policy "${formatPolicyNames(roleFailures.inline.allActionsSelectedResources)}" allows all actions on selected resources`); + } + if (roleFailures.inline.actionsAllResources.length) { + messages.push(`Role inline policy "${formatPolicyNames(roleFailures.inline.actionsAllResources)}" allows actions on all resources`); + } + for (let action in roleFailures.inline.wildcardActions) { + messages.push(`Role inline policy "${roleFailures.inline.wildcardActions[action].join('", "')}" allows wildcard actions: ${action}`); + } + for (let resource in roleFailures.inline.regexMismatch) { + messages.push(`Role inline policy "${roleFailures.inline.regexMismatch[resource].join('", "')}" does not match provided regex: ${resource}`); + } + } + + helpers.addResult(results, 2, + messages.join('\n'), + 'global', role.Arn, custom); + } else { + helpers.addResult(results, 0, + 'Role does not have overly-permissive policy', + 'global', role.Arn, custom); + } } \ No newline at end of file diff --git a/plugins/aws/iam/iamRolePolicies.spec.js b/plugins/aws/iam/iamRolePolicies.spec.js index 0e336d1f6c..25fdcc3b0e 100644 --- a/plugins/aws/iam/iamRolePolicies.spec.js +++ b/plugins/aws/iam/iamRolePolicies.spec.js @@ -393,7 +393,7 @@ describe('iamRolePolicies', function () { const cache = createCache([listRoles[0]],getRole[0], {}, listRolePolicies[1], getRolePolicy[4]); iamRolePolicies.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); - expect(results[0].message).to.include('policy allows all actions on selected resources'); + expect(results[0].message).to.include('allows all actions on selected resources'); expect(results[0].status).to.equal(2); done(); }); @@ -403,7 +403,7 @@ describe('iamRolePolicies', function () { const cache = createCache([listRoles[1]],getRole[0], {}, listRolePolicies[1], getRolePolicy[3]); iamRolePolicies.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); - expect(results[0].message).to.include('policy allows all actions on all resources'); + expect(results[0].message).to.include('allows all actions on all resources'); expect(results[0].status).to.equal(2); done(); }); diff --git a/plugins/aws/kinesisvideo/videostreamDataEncrypted.js b/plugins/aws/kinesisvideo/videostreamDataEncrypted.js index a1146e4225..940cefd8ca 100644 --- a/plugins/aws/kinesisvideo/videostreamDataEncrypted.js +++ b/plugins/aws/kinesisvideo/videostreamDataEncrypted.js @@ -11,7 +11,7 @@ module.exports = { 'It is recommended to use customer-managed keys (CMKs) for encryption in order to gain more granular control over encryption/decryption process.', recommended_action: 'Encrypt Kinesis Video Streams data with customer-manager keys (CMKs).', link: 'https://docs.aws.amazon.com/kinesisvideostreams/latest/dg/how-kms.html', - apis: ['KinesisVideo:listStreams', 'KMS:describeKey', 'KMS:listKeys'], + apis: ['KinesisVideo:listStreams', 'KMS:describeKey', 'KMS:listKeys', 'KMS:listAliases'], settings: { video_stream_data_desired_encryption_level: { name: 'Kinesis Video Streams Data Target Encryption Level', @@ -59,16 +59,39 @@ module.exports = { return rcb(); } + var listAliases = helpers.addSource(cache, source, + ['kms', 'listAliases', region]); + + if (!listAliases || listAliases.err || !listAliases.data) { + helpers.addResult(results, 3, + 'Unable to query for KMS aliases: ' + helpers.addError(listAliases), + region); + return rcb(); + } + + var keyArn; + var kmsAliasArnMap = {}; + listAliases.data.forEach(function(alias) { + keyArn = alias.AliasArn.replace(/:alias\/.*/, ':key/' + alias.TargetKeyId); + kmsAliasArnMap[alias.AliasName] = keyArn; + }); + for (let streamData of listStreams.data) { if (!streamData.StreamARN) continue; let resource = streamData.StreamARN; if (streamData.KmsKeyId) { - var kmsKeyId = streamData.KmsKeyId.split('/')[1] ? streamData.KmsKeyId.split('/')[1] : streamData.KmsKeyId; + + let aliasKey = streamData.KmsKeyId.includes('alias/') ? streamData.KmsKeyId.split(':').pop() : streamData.KmsKeyId; + let kmsKeyArn = (aliasKey.startsWith('alias/')) + ? (kmsAliasArnMap[aliasKey] ? kmsAliasArnMap[aliasKey] : streamData.KmsKeyId) + : streamData.KmsKeyId; + var kmsKeyId = kmsKeyArn.split('/')[1] ? kmsKeyArn.split('/')[1] : kmsKeyArn; + var describeKey = helpers.addSource(cache, source, - ['kms', 'describeKey', region, kmsKeyId]); + ['kms', 'describeKey', region, kmsKeyId]); if (!describeKey || describeKey.err || !describeKey.data || !describeKey.data.KeyMetadata) { helpers.addResult(results, 3, diff --git a/plugins/aws/kinesisvideo/videostreamDataEncrypted.spec.js b/plugins/aws/kinesisvideo/videostreamDataEncrypted.spec.js index 3dc7073d33..0c7177eecc 100644 --- a/plugins/aws/kinesisvideo/videostreamDataEncrypted.spec.js +++ b/plugins/aws/kinesisvideo/videostreamDataEncrypted.spec.js @@ -21,6 +21,14 @@ const listKeys = [ } ]; +const listAliases = [ + { + "AliasName": "alias/my-kinesis-key", + "AliasArn": "arn:aws:kms:us-east-1:000011112222:alias/my-kinesis-key", + "TargetKeyId": "ad013a33-b01d-4d88-ac97-127399c18b3e" + } +]; + const describeKey = [ { "KeyMetadata": { @@ -60,7 +68,7 @@ const describeKey = [ } ]; -const createCache = (streamData, keys, describeKey, streamDataErr, keysErr, describeKeyErr) => { +const createCache = (streamData, keys, aliases, describeKey, streamDataErr, keysErr, aliasesErr, describeKeyErr) => { var keyId = (keys && keys.length ) ? keys[0].KeyId : null; return { kinesisvideo: { @@ -78,6 +86,12 @@ const createCache = (streamData, keys, describeKey, streamDataErr, keysErr, desc err: keysErr } }, + listAliases: { + 'us-east-1': { + data: aliases, + err: aliasesErr + } + }, describeKey: { 'us-east-1': { [keyId]: { @@ -95,8 +109,8 @@ const createCache = (streamData, keys, describeKey, streamDataErr, keysErr, desc describe('videostreamDataEncrypted', function () { describe('run', function () { - it('should PASS if Kinesis Video Streams data is using desired encryption level', function (done) { - const cache = createCache(listStreams, listKeys, describeKey[0]); + it('should PASS if Kinesis Video Streams data is using customer-managed encryption (awscmk)', function (done) { + const cache = createCache(listStreams, listKeys, listAliases, describeKey[0]); videostreamDataEncrypted.run(cache, { video_stream_data_desired_encryption_level: 'awscmk' }, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); @@ -106,9 +120,9 @@ describe('videostreamDataEncrypted', function () { }); - it('should FAIL if Kinesis Video Streams data is using desired encyption level', function (done) { - const cache = createCache(listStreams, listKeys, describeKey[1]); - videostreamDataEncrypted.run(cache, { video_stream_data_desired_encryption_level:'awscmk' }, (err, results) => { + it('should FAIL if Kinesis Video Streams data is using AWS managed encryption (awskms)', function (done) { + const cache = createCache(listStreams, listKeys, listAliases, describeKey[1]); + videostreamDataEncrypted.run(cache, { video_stream_data_desired_encryption_level: 'awscmk' }, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); expect(results[0].message).to.include('Kinesis Video Streams data is using awskms'); @@ -117,7 +131,7 @@ describe('videostreamDataEncrypted', function () { }); - it('should PASS if no Kinesis Video Streams found', function (done) { + it('should PASS if no Kinesis Video Streams are found', function (done) { const cache = createCache([]); videostreamDataEncrypted.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); @@ -128,7 +142,7 @@ describe('videostreamDataEncrypted', function () { }); it('should UNKNOWN if unable to list Kinesis Video Streams', function (done) { - const cache = createCache(null, null, null, { message: "Unable to list Kinesis Video Streams encryption" }); + const cache = createCache(null, null, null, null, { message: "Unable to list Kinesis Video Streams" }); videostreamDataEncrypted.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(3); @@ -138,7 +152,16 @@ describe('videostreamDataEncrypted', function () { }); it('should UNKNOWN if unable to list KMS keys', function (done) { - const cache = createCache(null, null, null, null, { message: "Unable to list KMS keys" }); + const cache = createCache(null, null, null, null, null, { message: "Unable to list KMS keys" }); + videostreamDataEncrypted.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + done(); + }); + }); + + it('should UNKNOWN if unable to retrieve KMS alias data', function (done) { + const cache = createCache(listStreams, listKeys, null, describeKey[0], null, null, { message: "Unable to list KMS aliases" }); videostreamDataEncrypted.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(3); diff --git a/plugins/aws/kms/kmsDefaultKeyUsage.js b/plugins/aws/kms/kmsDefaultKeyUsage.js index 8a309c8659..495613bb07 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/lambda/lambdaNetworkExposure.js b/plugins/aws/lambda/lambdaNetworkExposure.js new file mode 100644 index 0000000000..891b16e8d5 --- /dev/null +++ b/plugins/aws/lambda/lambdaNetworkExposure.js @@ -0,0 +1,82 @@ +var async = require('async'); +var helpers = require('../../../helpers/aws'); + +module.exports = { + title: 'Internet Exposure', + category: 'Lambda', + domain: 'Serverless', + severity: 'Info', + description: 'Check if Lambda functions are exposed to the internet.', + more_info: 'Lambda functions can be exposed to the internet through Function URLs with public access policies or through API Gateway integrations. It\'s important to ensure these endpoints are properly secured.', + link: 'https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html', + recommended_action: 'Ensure Lambda Function URLs have proper authorization configured and API Gateway integrations use appropriate security measures.', + apis: ['Lambda:listFunctions', 'Lambda:getFunctionUrlConfig', 'Lambda:getPolicy', + 'APIGateway:getRestApis','APIGateway:getResources', 'APIGateway:getStages', 'APIGateway:getIntegration', 'ELBv2:describeLoadBalancers', 'ELBv2:describeTargetGroups', + 'ELBv2:describeTargetHealth', 'ELBv2:describeListeners', 'EC2:describeSecurityGroups'], + realtime_triggers: ['lambda:CreateFunctionUrlConfig', 'lambda:UpdateFunctionUrlConfig', 'lambda:DeleteFunctionUrlConfig', + 'lambda:AddPermission', 'lambda:RemovePermission', + 'apigateway:CreateRestApi', 'apigateway:DeleteRestApi', 'apigateway:UpdateRestApi', + 'apigateway:CreateStage', 'apigateway:DeleteStage', 'apigateway:UpdateStage', + 'apigateway:PutIntegration', 'apigateway:DeleteIntegration'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(settings); + + async.each(regions.lambda, function(region, rcb) { + var listFunctions = helpers.addSource(cache, source, + ['lambda', 'listFunctions', region]); + + if (!listFunctions) return rcb(); + + if (listFunctions.err || !listFunctions.data) { + helpers.addResult(results, 3, + 'Unable to query for Lambda functions: ' + helpers.addError(listFunctions), region); + return rcb(); + } + + if (!listFunctions.data.length) { + helpers.addResult(results, 0, 'No Lambda functions found', region); + return rcb(); + } + + let lambdaELBMap = helpers.getLambdaTargetELBs(cache, source, region); + + for (var lambda of listFunctions.data) { + if (!lambda.FunctionArn) continue; + + // Get function URL config and policy for Lambda-specific checks + var getFunctionUrlConfig = helpers.addSource(cache, source, + ['lambda', 'getFunctionUrlConfig', region, lambda.FunctionName]); + + var getPolicy = helpers.addSource(cache, source, + ['lambda', 'getPolicy', region, lambda.FunctionName]); + + let lambdaResource = { + functionUrlConfig: getFunctionUrlConfig, + functionPolicy: getPolicy, + functionArn: lambda.FunctionArn + }; + + let targetingELBs = lambdaELBMap[lambda.FunctionArn] || []; + + let internetExposed = helpers.checkNetworkExposure(cache, source, [], [], targetingELBs, region, results, lambdaResource); + + if (internetExposed && internetExposed.length) { + helpers.addResult(results, 2, + `Lambda function is exposed to the internet through: ${internetExposed}`, + region, lambda.FunctionArn); + } else { + helpers.addResult(results, 0, + 'Lambda function is not exposed to the internet', + region, lambda.FunctionArn); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/aws/lambda/lambdaOldRuntimes.js b/plugins/aws/lambda/lambdaOldRuntimes.js index 19a11a73f7..994360ce25 100644 --- a/plugins/aws/lambda/lambdaOldRuntimes.js +++ b/plugins/aws/lambda/lambdaOldRuntimes.js @@ -40,6 +40,7 @@ module.exports = { { 'id':'nodejs12.x', 'name': 'Node.js 12', 'endOfLifeDate': '2023-03-31'}, { 'id':'nodejs14.x', 'name': 'Node.js 14', 'endOfLifeDate': '2023-12-04'}, { 'id':'nodejs16.x', 'name': 'Node.js 16', 'endOfLifeDate': '2024-06-12'}, + { 'id':'nodejs18.x', 'name': 'Node.js 18', 'endOfLifeDate': '2025-04-30'}, { 'id':'dotnetcore3.1', 'name': '.Net Core 3.1', 'endOfLifeDate': '2023-04-03' }, { 'id':'dotnetcore2.1', 'name': '.Net Core 2.1', 'endOfLifeDate': '2022-01-05' }, { 'id':'dotnetcore2.0', 'name': '.Net Core 2.0', 'endOfLifeDate': '2019-05-30' }, diff --git a/plugins/aws/lambda/lambdaOldRuntimes.spec.js b/plugins/aws/lambda/lambdaOldRuntimes.spec.js index d49bcdf987..6087987a55 100644 --- a/plugins/aws/lambda/lambdaOldRuntimes.spec.js +++ b/plugins/aws/lambda/lambdaOldRuntimes.spec.js @@ -5,7 +5,7 @@ const listFunctions = [ { "FunctionName": "test-lambda", "FunctionArn": "arn:aws:lambda:us-east-1:000011112222:function:test-lambda", - "Runtime": "nodejs18.x", + "Runtime": "nodejs22.x", "Role": "arn:aws:iam::000011112222:role/lambda-role", "Handler": "index.handler", "TracingConfig": { "Mode": "PassThrough" } diff --git a/plugins/aws/lambda/lambdaPrivilegeAnalysis.js b/plugins/aws/lambda/lambdaPrivilegeAnalysis.js new file mode 100644 index 0000000000..4856c1be89 --- /dev/null +++ b/plugins/aws/lambda/lambdaPrivilegeAnalysis.js @@ -0,0 +1,20 @@ +module.exports = { + title: 'Privilege Analysis', + category: 'Lambda', + domain: 'Serverless', + severity: 'Info', + description: 'Ensures no Lambda function available in your AWS account is overly-permissive.', + more_info: 'AWS Lambda Function should have most-restrictive IAM permissions for Lambda security best practices.', + link: 'https://docs.aws.amazon.com/lambda/latest/dg/lambda-permissions.html', + recommended_action: 'Modify IAM role attached with Lambda function to provide the minimal amount of access required to perform its tasks', + apis: [''], + realtime_triggers: ['lambda:CreateFunction','lambda:UpdateFunctionConfiguration', 'lambda:DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + callback(null, results, source); + + } +}; diff --git a/plugins/aws/neptune/neptuneDBIamAuth.js b/plugins/aws/neptune/neptuneDBIamAuth.js index e4476cfc5a..5187ad1061 100644 --- a/plugins/aws/neptune/neptuneDBIamAuth.js +++ b/plugins/aws/neptune/neptuneDBIamAuth.js @@ -37,7 +37,7 @@ module.exports = { } for (let cluster of describeDBClusters.data) { - if (!cluster.DBClusterArn) continue; + if (!cluster.DBClusterArn || cluster.Engine !== 'neptune') continue; if (cluster.IAMDatabaseAuthenticationEnabled) { helpers.addResult(results, 0, 'Neptune database instance has IAM authentication enabled', region, cluster.DBClusterArn); diff --git a/plugins/aws/neptune/neptuneDBIamAuth.spec.js b/plugins/aws/neptune/neptuneDBIamAuth.spec.js index 931edf0b63..5a97526354 100644 --- a/plugins/aws/neptune/neptuneDBIamAuth.spec.js +++ b/plugins/aws/neptune/neptuneDBIamAuth.spec.js @@ -4,6 +4,7 @@ var neptuneDBIamAuth = require('./neptuneDBIamAuth'); const describeDBClusters = [ { "AllocatedStorage": 1, + "Engine": "neptune", "BackupRetentionPeriod": 1, "DbClusterResourceId": "cluster-WNY2ZTZWH4RQ2CTKEEP4GVCPU4", "DBClusterArn": "arn:aws:rds:us-east-1:000111222333:cluster:database-2", @@ -12,6 +13,7 @@ const describeDBClusters = [ }, { "AllocatedStorage": 1, + "Engine": "neptune", "BackupRetentionPeriod": 1, "DbClusterResourceId": "cluster-WNY2ZTZWH4RQ2CTKEEP4GVCPU9", "DBClusterArn": "arn:aws:rds:us-east-1:000111222334:cluster:database-3", diff --git a/plugins/aws/neptune/neptuneDBInstanceEncrypted.js b/plugins/aws/neptune/neptuneDBInstanceEncrypted.js index 5eea32e9c3..5ae37b307f 100644 --- a/plugins/aws/neptune/neptuneDBInstanceEncrypted.js +++ b/plugins/aws/neptune/neptuneDBInstanceEncrypted.js @@ -63,7 +63,7 @@ module.exports = { } for (let cluster of describeDBClusters.data) { - if (!cluster.DBClusterArn) continue; + if (!cluster.DBClusterArn || cluster.Engine !== 'neptune') continue; let resource = cluster.DBClusterArn; diff --git a/plugins/aws/neptune/neptuneDBMultiAz.js b/plugins/aws/neptune/neptuneDBMultiAz.js index b6b8437666..01c49ea404 100644 --- a/plugins/aws/neptune/neptuneDBMultiAz.js +++ b/plugins/aws/neptune/neptuneDBMultiAz.js @@ -38,7 +38,7 @@ module.exports = { } for (let cluster of describeDBClusters.data) { - if (!cluster.DBClusterArn) continue; + if (!cluster.DBClusterArn || cluster.Engine !== 'neptune') continue; let resource = cluster.DBClusterArn; diff --git a/plugins/aws/neptune/neptuneDBMultiAz.spec.js b/plugins/aws/neptune/neptuneDBMultiAz.spec.js index e5335b5f56..0c7c827a9f 100644 --- a/plugins/aws/neptune/neptuneDBMultiAz.spec.js +++ b/plugins/aws/neptune/neptuneDBMultiAz.spec.js @@ -8,6 +8,7 @@ const describeDBClusters = [ "DbClusterResourceId": "cluster-WNY2ZTZWH4RQ2CTKEEP4GVCPU4", "DBClusterArn": "arn:aws:rds:us-east-1:000111222333:cluster:database-2", "AssociatedRoles": [], + "Engine": "neptune", "MultiAZ": true }, { @@ -16,6 +17,7 @@ const describeDBClusters = [ "DbClusterResourceId": "cluster-WNY2ZTZWH4RQ2CTKEEP4GVCPU9", "DBClusterArn": "arn:aws:rds:us-east-1:000111222334:cluster:database-3", "AssociatedRoles": [], + "Engine": "neptune", "MultiAZ": false } ]; diff --git a/plugins/aws/neptune/neptuneDbDeletionProtection.js b/plugins/aws/neptune/neptuneDbDeletionProtection.js index d7f0211834..0f333a0435 100644 --- a/plugins/aws/neptune/neptuneDbDeletionProtection.js +++ b/plugins/aws/neptune/neptuneDbDeletionProtection.js @@ -37,8 +37,7 @@ module.exports = { } for (let cluster of describeDBClusters.data) { - if (!cluster.DBClusterArn) continue; - + if (!cluster.DBClusterArn || cluster.Engine != 'neptune') continue; if (cluster.DeletionProtection) { helpers.addResult(results, 0, 'Neptune database instance has deletion protection enabled', region, cluster.DBClusterArn); } else { diff --git a/plugins/aws/neptune/neptuneDbDeletionProtection.spec.js b/plugins/aws/neptune/neptuneDbDeletionProtection.spec.js index a82c6ca31c..082b291ecc 100644 --- a/plugins/aws/neptune/neptuneDbDeletionProtection.spec.js +++ b/plugins/aws/neptune/neptuneDbDeletionProtection.spec.js @@ -8,6 +8,7 @@ const describeDBClusters = [ "DbClusterResourceId": "cluster-WNY2ZTZWH4RQ2CTKEEP4GVCPU4", "DBClusterArn": "arn:aws:rds:us-east-1:000111222333:cluster:database-2", "AssociatedRoles": [], + "Engine": "neptune", "DeletionProtection": true }, { @@ -16,6 +17,7 @@ const describeDBClusters = [ "DbClusterResourceId": "cluster-WNY2ZTZWH4RQ2CTKEEP4GVCPU9", "DBClusterArn": "arn:aws:rds:us-east-1:000111222334:cluster:database-3", "AssociatedRoles": [], + "Engine": "neptune", "DeletionProtection": false } ]; diff --git a/plugins/aws/neptune/neptuneInstanceBackupRetention.js b/plugins/aws/neptune/neptuneInstanceBackupRetention.js index 3b10aca312..c491a872e1 100644 --- a/plugins/aws/neptune/neptuneInstanceBackupRetention.js +++ b/plugins/aws/neptune/neptuneInstanceBackupRetention.js @@ -46,7 +46,7 @@ module.exports = { } for (let cluster of describeDBClusters.data) { - if (!cluster.DBClusterArn) continue; + if (!cluster.DBClusterArn || cluster.Engine !== 'neptune') continue; let resource = cluster.DBClusterArn; @@ -67,3 +67,4 @@ module.exports = { }); } }; + diff --git a/plugins/aws/rds/rdsPublicSubnet.js b/plugins/aws/rds/rdsPublicSubnet.js index 43d808a4cf..20249149cd 100644 --- a/plugins/aws/rds/rdsPublicSubnet.js +++ b/plugins/aws/rds/rdsPublicSubnet.js @@ -8,7 +8,7 @@ module.exports = { severity: 'High', description: 'Ensures RDS database instances are not deployed in public subnet.', more_info: 'RDS instances should not be deployed in public subnets to prevent direct exposure to the internet and reduce the risk of unauthorized access.', - link: 'https://docs.aws.amazon.com/config/latest/developerguide/rds-instance-public-access-check.html', + link: 'https://docs.aws.amazon.com/config/latest/developerguide/rds-instance-subnet-igw-check.html', recommended_action: 'Replace the subnet groups of rds instance with the private subnets.', apis: ['RDS:describeDBInstances', 'EC2:describeRouteTables', 'EC2:describeSubnets'], realtime_triggers: ['rds:CreateDBInstance', 'rds:ModifyDBInstance', 'rds:RestoreDBInstanceFromDBSnapshot', 'rds:RestoreDBInstanceFromS3','rds:DeleteDBInstance'], diff --git a/plugins/aws/route53/domainExpiry.js b/plugins/aws/route53/domainExpiry.js index cf2a9869ed..0fd5ba80e3 100644 --- a/plugins/aws/route53/domainExpiry.js +++ b/plugins/aws/route53/domainExpiry.js @@ -42,7 +42,7 @@ module.exports = { if (difference > 35) { helpers.addResult(results, 0, returnMsg, 'global', domain.DomainName); - } else if (domain.DomainName.endsWith(('.com.ar, .com.br, .jp')) && difference > 30) { + } else if (['.com.ar', '.com.br', '.jp'].some(suffix => domain.DomainName.endsWith(suffix)) && difference > 30){ helpers.addResult(results, 0, returnMsg, 'global', domain.DomainName); } else if (difference > 0) { helpers.addResult(results, 2, returnMsg, 'global', domain.DomainName); diff --git a/plugins/aws/route53/domainTransferLock.js b/plugins/aws/route53/domainTransferLock.js index 10c0e41422..4a671f32ed 100644 --- a/plugins/aws/route53/domainTransferLock.js +++ b/plugins/aws/route53/domainTransferLock.js @@ -34,12 +34,39 @@ module.exports = { return callback(null, results, source); } + var dtlUnsupportedDomains= [ + '.za', + '.cl', + '.ar', + '.au', + '.nz', + '.au', + '.jp', + '.qa', + '.ru', + '.ch', + '.de', + '.es', + '.eu', + 'fi', + '.it', + '.nl', + '.se', + ]; + var unsupported = false; + for (var i in listDomains.data) { var domain = listDomains.data[i]; + if (!domain.DomainName) continue; - // Skip .uk and .co.uk domains - if (domain.DomainName.indexOf('.uk') > -1) { + dtlUnsupportedDomains.forEach((region) => { + if (domain.DomainName.includes(region)) { + unsupported = true; + } + }); + // Skip the unsupported domains + if (unsupported) { helpers.addResult(results, 0, 'Domain: ' + domain.DomainName + ' does not support transfer locks', 'global', domain.DomainName); diff --git a/plugins/aws/route53/domainTransferLock.spec.js b/plugins/aws/route53/domainTransferLock.spec.js index 889aee3be5..441a0548e9 100644 --- a/plugins/aws/route53/domainTransferLock.spec.js +++ b/plugins/aws/route53/domainTransferLock.spec.js @@ -15,7 +15,7 @@ const domains = [ "Expiry": 1602712345.0 }, { - "DomainName": "example.com.uk", + "DomainName": "example.com.jp", "AutoRenew": true, "TransferLock": true, "Expiry": 1602712345.0 @@ -23,7 +23,7 @@ const domains = [ ] -const createCache = (domain, domainErr) => { +const createCache = (domain, domainErr) => { return { route53domains: { listDomains: { @@ -98,4 +98,4 @@ describe('domainTransferLock', function () { }); }); }); -}); \ No newline at end of file +}); diff --git a/plugins/aws/s3/bucketSecureTransportEnabled.spec.js b/plugins/aws/s3/bucketSecureTransportEnabled.spec.js index 7694f8597a..49f1addf20 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:::cloudsploit-test-secure-transport/*"]},{"Sid":"Stmt1537431944211","Effect":"Deny","Principal":"*","Action":"s3:*","Resource":["arn:aws:s3:::cloudsploit-test-secure-transport/*","arn:aws:s3:::cloudsploit-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 d225153deb..155497837d 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); } }; diff --git a/plugins/aws/s3/s3BucketHasTags.spec.js b/plugins/aws/s3/s3BucketHasTags.spec.js index c5d16a432d..5eefe11090 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/aws/s3/s3Encryption.js b/plugins/aws/s3/s3Encryption.js index 6289677708..35ebea1dfa 100644 --- a/plugins/aws/s3/s3Encryption.js +++ b/plugins/aws/s3/s3Encryption.js @@ -155,7 +155,7 @@ module.exports = { if (encryptionLevel.level) return encryptionLevel.level; if (encryptionLevel.key) { const keyId = encryptionLevel.key.split('/')[1]; - const describeKey = helpers.addSource(cache, source, ['kms', 'describeKey', region, keyId]); + const describeKey = helpers.addSource(cache, source, ['kms', 'describeKey', bucketLocation, keyId]); if (!describeKey || describeKey.err || !describeKey.data) { helpers.addResult(results, 3, `Unable to query for KMS Key: ${helpers.addError(describeKey)}`, region, keyId); return 0; diff --git a/plugins/aws/securityhub/securityHubActiveFindings.js b/plugins/aws/securityhub/securityHubActiveFindings.js index 30f9d165e4..ccf32f7509 100644 --- a/plugins/aws/securityhub/securityHubActiveFindings.js +++ b/plugins/aws/securityhub/securityHubActiveFindings.js @@ -43,10 +43,10 @@ module.exports = { var resource = describeHub.data.HubArn; const getFindings = helpers.addSource(cache, source, ['securityhub', 'getFindings', region]); - if (!getFindings) { + if (!getFindings || !getFindings.data) { helpers.addResult(results, 0, 'No active findings available', region, resource); return rcb(); - } else if (getFindings.err || !getFindings.data ) { + } else if (getFindings.err) { helpers.addResult(results, 3, `Unable to get SecurityHub findings: ${helpers.addError(getFindings)}`, region, resource); } else if (!getFindings.data.length) { helpers.addResult(results, 0, 'No active findings available', region, resource); @@ -54,16 +54,21 @@ module.exports = { } else { let activeFindings = getFindings.data.filter(finding => finding.CreatedAt && helpers.hoursBetween(new Date, finding.CreatedAt) > config.securityhub_findings_fail); - let status = (activeFindings && activeFindings.length) ? 2 : 0; - - helpers.addResult(results, status, - `Security Hub has ${status == 0 ? 0 : activeFindings.length} active finding(s)`, region, resource); + + if (!activeFindings.length) { + helpers.addResult(results, 0, + 'Security Hub has no active findings', region, resource); + } else { + helpers.addResult(results, 2, + `Security Hub has over ${activeFindings.length} active findings`, region, resource); + } } - } + + } return rcb(); }, function() { callback(null, results, source); }); } -}; \ No newline at end of file +}; diff --git a/plugins/aws/securityhub/securityHubActiveFindings.spec.js b/plugins/aws/securityhub/securityHubActiveFindings.spec.js index 7ddb72010c..75df0f0500 100644 --- a/plugins/aws/securityhub/securityHubActiveFindings.spec.js +++ b/plugins/aws/securityhub/securityHubActiveFindings.spec.js @@ -96,7 +96,7 @@ describe('securityHubActiveFindings', function () { 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.equal('Security Hub has 0 active finding(s)'); + expect(results[0].message).to.equal('Security Hub has no active findings'); done(); }); }); @@ -107,7 +107,7 @@ describe('securityHubActiveFindings', function () { 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.equal('Security Hub has 1 active finding(s)'); + expect(results[0].message).to.includes('Security Hub has over'); done(); }); }); diff --git a/plugins/aws/ses/dkimEnabled.js b/plugins/aws/ses/dkimEnabled.js index 9e753b3f53..cf3937c77c 100644 --- a/plugins/aws/ses/dkimEnabled.js +++ b/plugins/aws/ses/dkimEnabled.js @@ -51,9 +51,9 @@ module.exports = { return rcb(); } - for (var i in getIdentityDkimAttributes.data.DkimAttributes) { - var resource = `arn:${awsOrGov}:ses:${region}:${accountId}:identity/${i}`; - var identity = getIdentityDkimAttributes.data.DkimAttributes[i]; + for (var identity of getIdentityDkimAttributes.data.DkimAttributes) { + if (!identity.identityName) continue; + var resource = `arn:${awsOrGov}:ses:${region}:${accountId}:identity/${identity.identityName}`; if (!identity.DkimEnabled) { helpers.addResult(results, 2, 'DKIM is not enabled', region, resource); diff --git a/plugins/aws/ses/dkimEnabled.spec.js b/plugins/aws/ses/dkimEnabled.spec.js index ba5165fc76..4112724f20 100644 --- a/plugins/aws/ses/dkimEnabled.spec.js +++ b/plugins/aws/ses/dkimEnabled.spec.js @@ -9,6 +9,7 @@ const listIdentities = [ const getIdentityDkimAttributes = [ { "DkimEnabled": true, + "identityName": 'abc.com', "DkimVerificationStatus": "Pending", "DkimTokens": [ "otux44vv2jf7bme4j6y7qyagkni466lo", @@ -18,6 +19,7 @@ const getIdentityDkimAttributes = [ }, { "DkimEnabled": true, + "identityName": 'test.com', "DkimVerificationStatus": "Success", "DkimTokens": [ "otux44vv2jf7bme4j6y7qyagkni466lo", @@ -27,6 +29,7 @@ const getIdentityDkimAttributes = [ }, { "DkimEnabled": false, + "identityName": 'test.com', "DkimVerificationStatus": "Pending", "DkimTokens": [ "otux44vv2jf7bme4j6y7qyagkni466lo", diff --git a/plugins/aws/workspaces/unusedWorkspaces.js b/plugins/aws/workspaces/unusedWorkspaces.js index a17a62fc42..4259660f50 100644 --- a/plugins/aws/workspaces/unusedWorkspaces.js +++ b/plugins/aws/workspaces/unusedWorkspaces.js @@ -7,10 +7,18 @@ module.exports = { domain: 'Identity and Access Management', severity: 'High', description: 'Ensure that there are no unused AWS WorkSpaces instances available within your AWS account.', - more_info: 'An AWS WorkSpaces instance is considered unused if it has 0 known user connections registered within the past 30 days. Remove these instances to avoid unnecessary billing.', + more_info: 'An AWS WorkSpaces instance is considered unused if it has 0 known user connections registered within the configured inactivity threshold. Remove these instances to avoid unnecessary billing.', link: 'https://aws.amazon.com/workspaces/pricing/', recommended_action: 'Identify and remove unused Workspaces instance', apis: ['WorkSpaces:describeWorkspacesConnectionStatus','STS:getCallerIdentity'], + settings: { + workspaces_inactivity_threshold_days: { + name: 'WorkSpaces Inactivity Threshold (Days)', + description: 'Number of days of inactivity before a WorkSpace is considered unused', + regex: '^[0-9]{1,4}$', + default: '30' + } + }, realtime_triggers: ['workspace:CreateWorkSpaces','workspace:TerminateWorkspaces'], run: function(cache, settings, callback) { @@ -18,6 +26,10 @@ module.exports = { var source = {}; var regions = helpers.regions(settings); + var config = { + workspaces_inactivity_threshold_days: parseInt(settings.workspaces_inactivity_threshold_days || this.settings.workspaces_inactivity_threshold_days.default) + }; + var awsOrGov = helpers.defaultPartition(settings); var acctRegion = helpers.defaultRegion(settings); var accountId = helpers.addSource(cache, source, ['sts', 'getCallerIdentity', acctRegion , 'data']); @@ -47,14 +59,16 @@ module.exports = { if (!workspace.LastKnownUserConnectionTimestamp) { helpers.addResult(results, 2, 'WorkSpace does not have any known user connection', region, resource); - } else if (workspace.LastKnownUserConnectionTimestamp && - (helpers.daysBetween(new Date(), workspace.LastKnownUserConnectionTimestamp)) > 30) { - helpers.addResult(results, 2, - `WorkSpace is not in use for last ${helpers.daysBetween(new Date(), workspace.LastKnownUserConnectionTimestamp)}`, - region, resource); } else { - helpers.addResult(results, 0, - 'WorkSpace is in use', region, resource); + var daysSinceLastConnection = helpers.daysBetween(new Date(), workspace.LastKnownUserConnectionTimestamp); + if (daysSinceLastConnection > config.workspaces_inactivity_threshold_days) { + helpers.addResult(results, 2, + `WorkSpace is not in use for last ${daysSinceLastConnection} days (threshold: ${config.workspaces_inactivity_threshold_days} days)`, + region, resource); + } else { + helpers.addResult(results, 0, + 'WorkSpace is in use', region, resource); + } } }); diff --git a/plugins/aws/workspaces/unusedWorkspaces.spec.js b/plugins/aws/workspaces/unusedWorkspaces.spec.js index dc41a2cc94..9cc1340073 100644 --- a/plugins/aws/workspaces/unusedWorkspaces.spec.js +++ b/plugins/aws/workspaces/unusedWorkspaces.spec.js @@ -12,10 +12,16 @@ const describeWorkspacesConnectionStatus = [ WorkspaceId: "test02", ConnectionState:"DISCONNECTED", ConnectionStateCheckTimestamp:"2021-10-04T08:56:18.935Z", - LastKnownUserConnectionTimestamp: "2020-10-04T08:56:18.935Z" + LastKnownUserConnectionTimestamp: new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString() }, { - WorkspaceId: "test02", + WorkspaceId: "test03", + ConnectionState:"DISCONNECTED", + ConnectionStateCheckTimestamp:"2021-10-04T08:56:18.935Z", + LastKnownUserConnectionTimestamp: new Date(Date.now() - 150 * 24 * 60 * 60 * 1000).toISOString() + }, + { + WorkspaceId: "test04", ConnectionState:"DISCONNECTED", ConnectionStateCheckTimestamp:"2021-10-04T08:56:18.935Z" }, @@ -70,12 +76,27 @@ describe('unusedWorkspaces', function () { 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('threshold: 30 days'); done(); }); }); - it('should FAIL if WorkSpace does not have any known user connection', function (done) { + it('should FAIL if Workspace is not in use for 150 days with 120 day threshold', function (done) { const cache = createCache([describeWorkspacesConnectionStatus[2]]); + const settings = { + workspaces_inactivity_threshold_days: '120' + }; + unusedWorkspaces.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('threshold: 120 days'); + done(); + }); + }); + + it('should FAIL if WorkSpace does not have any known user connection', function (done) { + const cache = createCache([describeWorkspacesConnectionStatus[3]]); unusedWorkspaces.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); diff --git a/plugins/aws/workspaces/workspacesIpAccessControl.js b/plugins/aws/workspaces/workspacesIpAccessControl.js index 06ebf4201f..33def0c3c8 100644 --- a/plugins/aws/workspaces/workspacesIpAccessControl.js +++ b/plugins/aws/workspaces/workspacesIpAccessControl.js @@ -3,7 +3,7 @@ var helpers = require('../../../helpers/aws'); module.exports = { title: 'Workspaces IP Access Control', - category: 'Workspaces', + category: 'WorkSpaces', domain: 'Identity and Access Management', severity: 'Medium', description: 'Ensures enforced IP Access Control on Workspaces', diff --git a/plugins/azure/advisor/checkAdvisorRecommendations.js b/plugins/azure/advisor/checkAdvisorRecommendations.js index 242fd116cd..99bd9cf34e 100644 --- a/plugins/azure/advisor/checkAdvisorRecommendations.js +++ b/plugins/azure/advisor/checkAdvisorRecommendations.js @@ -8,7 +8,7 @@ module.exports = { severity: 'Medium', description: 'Ensure that all Microsoft Azure Advisor recommendations found are implemented to optimize your cloud deployments, increase security, and reduce costs.', more_info: 'Advisor service analyzes your Azure cloud configurations and resource usage telemetry to provide personalized and actionable recommendations that can help you optimize your cloud resources for security, reliability and high availability, operational excellence, performance efficiency, and cost.', - recommended_action: 'Implement all Microsoft Azurer Adivsor recommendations.', + recommended_action: 'Implement all Microsoft Azure Advisor recommendations.', link: 'https://learn.microsoft.com/en-us/azure/advisor/advisor-get-started', apis: ['advisor:recommendationsList'], diff --git a/plugins/azure/advisor/checkAdvisorRecommendations.spec.js b/plugins/azure/advisor/checkAdvisorRecommendations.spec.js index c8443c839c..54753ec2a4 100644 --- a/plugins/azure/advisor/checkAdvisorRecommendations.spec.js +++ b/plugins/azure/advisor/checkAdvisorRecommendations.spec.js @@ -40,7 +40,7 @@ const createCache = (err, recommendationsList) => { describe('checkAdvisorRecommendations', function() { describe('run', function() { - it('should give passing result if no Adivsor Recommendations are found', function(done) { + it('should give passing result if no Advisor Recommendations are found', function(done) { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) diff --git a/plugins/azure/apiManagement/apiInstanceManagedIdentity.js b/plugins/azure/apiManagement/apiInstanceManagedIdentity.js index 57557b0183..b08f064e40 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'], diff --git a/plugins/azure/appConfigurations/appConfigAccessKeyAuthDisabled.js b/plugins/azure/appConfigurations/appConfigAccessKeyAuthDisabled.js index 922bc1e66b..6ba03cc4b1 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'], diff --git a/plugins/azure/appConfigurations/appConfigManagedIdentity.js b/plugins/azure/appConfigurations/appConfigManagedIdentity.js index 1989c003f5..2bd8e7b51c 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'], diff --git a/plugins/azure/appservice/appServiceAccessRestriction.js b/plugins/azure/appservice/appServiceAccessRestriction.js index a3c0d433a3..2e0baed596 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); + } } } }); diff --git a/plugins/azure/appservice/appServiceAccessRestriction.spec.js b/plugins/azure/appservice/appServiceAccessRestriction.spec.js index 475e669087..64ac44b506 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/aqua-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'publicNetworkAccess': 'Enabled', 'ipSecurityRestrictions': [ { 'ipAddress': 'Any', @@ -23,6 +24,7 @@ const configurations = [ }, { 'id': '/subscriptions/123/resourceGroups/aqua-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/aqua-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'publicNetworkAccess': 'Disabled' + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-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/appServicePublicAccess.js b/plugins/azure/appservice/appServicePublicAccess.js new file mode 100644 index 0000000000..246ca91c29 --- /dev/null +++ b/plugins/azure/appservice/appServicePublicAccess.js @@ -0,0 +1,69 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'App Service Public Network Access Disabled', + category: 'App Service', + domain: 'Application Integration', + severity: 'High', + description: 'Ensure that Azure App Services have public network access disabled to prevent exposure of the application to the internet.', + more_info: 'By default, App Services may allow public network traffic unless explicitly disabled. Public network access can be disabled using the publicNetworkAccess property or by configuring a private endpoint. Disabling public network access ensures that your applications are only reachable through secure private endpoints and not exposed to the public internet.', + recommended_action: 'Set the Public network access setting to Disabled in the App Service Networking configuration, or configure a private endpoint to restrict access. You can do this via the Azure Portal, CLI, or ARM template.', + link: 'https://learn.microsoft.com/en-us/azure/app-service/overview-access-restrictions#ip-based-access-restriction-rules', + apis: ['webApps:list', 'webApps:listConfigurations'], + realtime_triggers: ['microsoftweb:sites:write', 'microsoftweb:sites:delete', 'microsoftweb:sites:config:write', 'microsoftweb:sites:config:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.webApps, function(location, rcb) { + var webApps = helpers.addSource(cache, source, + ['webApps', 'list', location]); + + if (!webApps) return rcb(); + + if (webApps.err || !webApps.data) { + helpers.addResult(results, 3, + 'Unable to query for App Services: ' + helpers.addError(webApps), location); + return rcb(); + } + + if (!webApps.data.length) { + helpers.addResult(results, 0, 'No existing App Services found', location); + return rcb(); + } + + webApps.data.forEach(function(webApp) { + if (!webApp.id) return; + + var webConfigs = helpers.addSource(cache, source, + ['webApps', 'listConfigurations', location, webApp.id]); + + if (!webConfigs || webConfigs.err || !webConfigs.data || !webConfigs.data.length) { + helpers.addResult(results, 3, + 'Unable to query App Service configuration: ' + helpers.addError(webConfigs), + location, webApp.id); + return; + } + + var config = webConfigs.data[0]; + + if (config.publicNetworkAccess && config.publicNetworkAccess.toLowerCase() === 'disabled') { + helpers.addResult(results, 0, + 'App Service has public network access disabled', + location, webApp.id); + } else { + helpers.addResult(results, 2, + 'App Service does not have public network access disabled', + location, webApp.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/appservice/appServicePublicAccess.spec.js b/plugins/azure/appservice/appServicePublicAccess.spec.js new file mode 100644 index 0000000000..cd732733d9 --- /dev/null +++ b/plugins/azure/appservice/appServicePublicAccess.spec.js @@ -0,0 +1,151 @@ +var expect = require('chai').expect; +var appServicePublicAccess = require('./appServicePublicAccess'); + +const webApps = [ + { + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app-1', + 'name': 'test-app-1', + 'type': 'Microsoft.Web/sites', + 'kind': 'app', + 'location': 'eastus' + }, + { + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app-2', + 'name': 'test-app-2', + 'type': 'Microsoft.Web/sites', + 'kind': 'app', + 'location': 'eastus' + }, + { + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app-3', + 'name': 'test-app-3', + 'type': 'Microsoft.Web/sites', + 'kind': 'functionapp', + 'location': 'eastus' + } +]; + +const listConfigurations = [ + { + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app-1/config/web', + 'name': 'web', + 'publicNetworkAccess': 'Disabled' + }, + { + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app-2/config/web', + 'name': 'web', + 'publicNetworkAccess': 'Enabled' + }, + { + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app-3/config/web', + 'name': 'web' + } +]; + +const createCache = (webApps, configurations, webAppsErr, configurationsErr) => { + const appId = (webApps && webApps.length) ? webApps[0].id : null; + return { + webApps: { + list: { + 'eastus': { + err: webAppsErr, + data: webApps + } + }, + listConfigurations: { + 'eastus': { + [appId]: { + err: configurationsErr, + data: configurations + } + } + } + } + }; +}; + +describe('appServicePublicAccess', function () { + describe('run', function () { + it('should give passing result if no web apps found', function (done) { + const cache = createCache([], null); + appServicePublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing App Services found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for web apps', function (done) { + const cache = createCache(null, null, { message: 'Unable to query Web Apps' }); + appServicePublicAccess.run(cache, {}, (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 App Services'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query web app configuration', function (done) { + const cache = createCache([webApps[0]], null, null, { message: 'Unable to query configuration' }); + appServicePublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query App Service configuration'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if App Service has public network access disabled', function (done) { + const cache = createCache([webApps[0]], [listConfigurations[0]]); + appServicePublicAccess.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 public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if App Service has public network access enabled', function (done) { + const cache = createCache([webApps[1]], [listConfigurations[1]]); + appServicePublicAccess.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 public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if App Service publicNetworkAccess property is not set', function (done) { + const cache = createCache([webApps[2]], [listConfigurations[2]]); + appServicePublicAccess.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 public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result for App Service with case-insensitive disabled value', function (done) { + const config = [{ + 'id': '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Web/sites/test-app-1/config/web', + 'name': 'web', + 'publicNetworkAccess': 'disabled' + }]; + const cache = createCache([webApps[0]], config); + appServicePublicAccess.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 public network access disabled'); + 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 a50f69d139..d880f6affc 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'], diff --git a/plugins/azure/appservice/automatedBackupsEnabled.js b/plugins/azure/appservice/automatedBackupsEnabled.js index c4517bf654..4448bb7b1b 100644 --- a/plugins/azure/appservice/automatedBackupsEnabled.js +++ b/plugins/azure/appservice/automatedBackupsEnabled.js @@ -2,13 +2,13 @@ var async = require('async'); var helpers = require('../../../helpers/azure'); module.exports = { - title: 'Web Apps Backup Enabled', + title: 'Web Apps Custom Backup Enabled', category: 'App Service', domain: 'Application Integration', severity: 'Medium', - description: 'Ensures that Azure Web Apps have automated backups enabled.', + description: 'Ensures that Azure Web Apps have custom automated backups enabled.', more_info: 'Protect your Azure App Services web applications against accidental deletion and/or corruption, you can configure application backups to create restorable copies of your app content.', - recommended_action: 'Configure backup for Azure Web Apps', + recommended_action: 'Configure custom automated backup for Azure Web Apps.', link: 'https://learn.microsoft.com/en-us/azure/app-service/manage-backup', apis: ['webApps:list', 'webApps:getBackupConfiguration'], realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete','microsoftweb:sites:config:write','microsoftweb:sites:config:delete'], @@ -44,10 +44,10 @@ module.exports = { ['webApps', 'getBackupConfiguration', location, webApp.id]); if (configs && configs.err && configs.err.includes('NotFound')) { - helpers.addResult(results, 2, 'Backups are not configured for WebApp', location, webApp.id); + helpers.addResult(results, 2, 'Custom Backups are not configured for WebApp', location, webApp.id); } else if (!configs || configs.err || !configs.data) { helpers.addResult(results, 3, 'Unable to query for Web App backup configs: ' + helpers.addError(configs), location, webApp.id); - } else helpers.addResult(results, 0, 'Backups are configured for WebApp', location, webApp.id); + } else helpers.addResult(results, 0, 'Custom Backups are configured for WebApp', location, webApp.id); }); rcb(); @@ -55,4 +55,4 @@ module.exports = { callback(null, results, source); }); } -}; +}; \ No newline at end of file diff --git a/plugins/azure/appservice/automatedBackupsEnabled.spec.js b/plugins/azure/appservice/automatedBackupsEnabled.spec.js index b9c8eb4bc4..8d71e358e5 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/functionAppNetworkExposure.js b/plugins/azure/appservice/functionAppNetworkExposure.js new file mode 100644 index 0000000000..77fec5e945 --- /dev/null +++ b/plugins/azure/appservice/functionAppNetworkExposure.js @@ -0,0 +1,131 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Internet Exposure', + category: 'App Service', + domain: 'Application Integration', + severity: 'Info', + description: 'Ensures that Azure function apps are not exposed to the internet.', + more_info: 'Azure Functions exposed to the internet are at higher risk of unauthorized access and exploitation. Securing access through proper configuration of authorization levels, IP restrictions, private endpoints, or service-specific security settings is critical to minimize vulnerabilities.', + recommended_action: 'Restrict Azure Function exposure by implementing secure access controls, such as authorization levels, IP restrictions, private endpoints, or integrating with VNETs.', + link: 'https://learn.microsoft.com/en-us/azure/azure-functions/functions-networking-options', + apis: ['webApps:list', 'applicationGateways:list', 'loadBalancers:list', 'classicFrontDoors:list', 'afdWafPolicies:listAll'], + realtime_triggers: ['microsoftweb:sites:write','microsoftweb:sites:delete', 'microsoftnetwork:applicationgateways:write', 'microsoftnetwork:applicationgateways:delete', 'microsoftnetwork:loadbalancers:write', 'microsoftnetwork:loadbalancers:delete', + 'microsoftnetwork:frontdoors:write', 'microsoftnetwork:frontdoors:delete', 'microsoftnetwork:frontdoorwebapplicationfirewallpolicies:write', 'microsoftnetwork:frontdoorwebapplicationfirewallpolicies:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + + async.each(locations.webApps, function(location, rcb) { + const webApps = helpers.addSource(cache, source, + ['webApps', 'list', location]); + + if (!webApps) return rcb(); + + if (webApps.err || !webApps.data) { + helpers.addResult(results, 3, + 'Unable to query for Function Apps: ' + helpers.addError(webApps), location); + return rcb(); + } + + if (webApps.data && webApps.data.length) { + webApps.data = webApps.data.filter(app => app.id && app.kind && app.kind.toLowerCase().includes('functionapp')); + } + + if (!webApps.data.length) { + helpers.addResult(results, 0, 'No existing Function Apps found', location); + return rcb(); + } + + const appGateways = helpers.addSource(cache, source, + ['applicationGateways', 'list', location]); + + const loadBalancers = helpers.addSource(cache, source, + ['loadBalancers', 'list', location]); + + + const frontDoors = helpers.addSource(cache, source, + ['classicFrontDoors', 'list', 'global']); + + + const wafPolicies = helpers.addSource(cache, source, + ['afdWafPolicies', 'listAll', 'global']); + + + for (let functionApp of webApps.data) { + let internetExposed = ''; + if (functionApp.publicNetworkAccess && functionApp.publicNetworkAccess === 'Enabled') { + internetExposed = 'public network access'; + } else { + let attachedResources = { + appGateways: [], + lbNames: [], + frontDoors: [] + }; + + // list attached app gateways + if (appGateways && !appGateways.err && appGateways.data && appGateways.data.length) { + attachedResources.appGateways = appGateways.data.filter(ag => + ag.backendAddressPools && ag.backendAddressPools.some(pool => + pool.backendAddresses && pool.backendAddresses.some(addr => + addr.fqdn === functionApp.properties.defaultHostName))); + } + + //list attached load balancers + if (loadBalancers && !loadBalancers.err && loadBalancers.data && loadBalancers.data.length) { + attachedResources.lbNames = loadBalancers.data.filter(lb => + lb.backendAddressPools && lb.backendAddressPools.some(pool => + pool.properties.backendIPConfigurations && + pool.properties.backendIPConfigurations.some(config => + config.id.toLowerCase().includes(functionApp.id.toLowerCase())))); + + attachedResources.lbNames = attachedResources.lbNames.map(lb => lb.name); + } + + // list attached front doors + if (frontDoors && !frontDoors.err && frontDoors.data && frontDoors.data.length) { + frontDoors.data.forEach(fd => { + const isFunctionAppBackend = fd.backendPools && fd.backendPools.some(pool => + pool.backends && pool.backends.some(backend => + backend.address === functionApp.properties.defaultHostName)); + + if (isFunctionAppBackend) { + fd.associatedWafPolicies = []; + + if (fd.frontendEndpoints && wafPolicies && !wafPolicies.err && wafPolicies.data && wafPolicies.data.length) { + fd.frontendEndpoints.forEach(endpoint => { + if (endpoint.webApplicationFirewallPolicyLink) { + const policyId = endpoint.webApplicationFirewallPolicyLink.id.toLowerCase(); + const matchingPolicy = wafPolicies.data.find(policy => + policy.id && policy.id.toLowerCase() === policyId); + if (matchingPolicy) { + fd.associatedWafPolicies.push(matchingPolicy); + } + } + }); + } + + attachedResources.frontDoors.push(fd); + } + }); + } + + internetExposed = helpers.checkNetworkExposure(cache, source, [], [], location, results, attachedResources, functionApp); + } + + if (internetExposed && internetExposed.length) { + helpers.addResult(results, 2, `Function App is exposed to the internet through ${internetExposed}`, location, functionApp.id); + } else { + helpers.addResult(results, 0, 'Function App is not exposed to the internet', location, functionApp.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/appservice/functionPrivilegeAnalysis.js b/plugins/azure/appservice/functionPrivilegeAnalysis.js new file mode 100644 index 0000000000..eba8673b97 --- /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); + }, +}; diff --git a/plugins/azure/appservice/privateEndpointsEnabled.js b/plugins/azure/appservice/privateEndpointsEnabled.js index 454216400b..17f8f002f6 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,17 +35,40 @@ 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); }); } diff --git a/plugins/azure/appservice/privateEndpointsEnabled.spec.js b/plugins/azure/appservice/privateEndpointsEnabled.spec.js index d8ea1f09b7..0fe0f99c4d 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/aqua-resource-group/providers/Microsoft.Web/sites/app1', 'name': 'app1', - 'privateLinkIdentifiers': '123456' + 'kind': 'app', + 'privateEndpointConnections': [ + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Web/sites/app1/privateEndpointConnections/test-endpoint', + 'name': 'test-endpoint' + } + ] }, { - 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Web/sites/app1', - 'name': 'app1', - 'privateLinkIdentifiers': '' + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Web/sites/app2', + 'name': 'app2', + 'kind': 'app', + 'privateEndpointConnections': [] + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Web/sites/func1', + 'name': 'func1', + 'kind': 'functionapp', + 'privateEndpointConnections': [ + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Web/sites/func1/privateEndpointConnections/func-endpoint', + 'name': 'func-endpoint' + } + ] + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-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 5539f199f8..f897e5408c 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); + } } } }); @@ -69,4 +77,6 @@ module.exports = { callback(null, results, source); }); } -}; \ No newline at end of file +}; + + diff --git a/plugins/azure/appservice/scmSiteAccessRestriction.spec.js b/plugins/azure/appservice/scmSiteAccessRestriction.spec.js index 16a754cb19..3aac3d0d38 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/aqua-resource-group/providers/Microsoft.Web/sites/app1/config/web', + 'publicNetworkAccess': 'Disabled', + 'scmIpSecurityRestrictions': [] + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-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 6c4caf0da4..a49de6d3cf 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); } } diff --git a/plugins/azure/appservice/webAppsADEnabled.spec.js b/plugins/azure/appservice/webAppsADEnabled.spec.js index 6e169f86d4..eaa41e1ccb 100644 --- a/plugins/azure/appservice/webAppsADEnabled.spec.js +++ b/plugins/azure/appservice/webAppsADEnabled.spec.js @@ -72,23 +72,23 @@ 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(); }); diff --git a/plugins/azure/automationAccounts/automationAcctExpiredWebhooks.spec.js b/plugins/azure/automationAccounts/automationAcctExpiredWebhooks.spec.js index 341fcb9109..bd714f3406 100644 --- a/plugins/azure/automationAccounts/automationAcctExpiredWebhooks.spec.js +++ b/plugins/azure/automationAccounts/automationAcctExpiredWebhooks.spec.js @@ -1,5 +1,7 @@ var expect = require('chai').expect; var automationAcctExpiredWebhooks = require('./automationAcctExpiredWebhooks'); +var nextMonthExpiry = new Date(); +nextMonthExpiry.setMonth(nextMonthExpiry.getMonth() + 1); const automationAccounts = [ { @@ -31,7 +33,7 @@ const webhooks = [ "id": "/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Automation/automationAccounts/test-automationacct/webhooks/test1", "name": "test1", "creationTime": "2024-01-22T13:33:52.1066667+00:00", - "expiryTime": "2025-01-22T13:33:52.1066667+00:00", + "expiryTime": nextMonthExpiry, }, { "id": "/subscriptions/12345/resourceGroups/test-rg/providers/Microsoft.Automation/automationAccounts/test-automationacct/webhooks/test2", @@ -180,6 +182,6 @@ describe('automationAcctExpiredWebhooks', function () { 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 bf3fbe4465..8f620522f9 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'], diff --git a/plugins/azure/automationAccounts/automationAcctPublicAccess.js b/plugins/azure/automationAccounts/automationAcctPublicAccess.js index 311ac71553..47e9d730f5 100644 --- a/plugins/azure/automationAccounts/automationAcctPublicAccess.js +++ b/plugins/azure/automationAccounts/automationAcctPublicAccess.js @@ -10,7 +10,7 @@ module.exports = { more_info: 'Disabling public network access ensures that network traffic between the machines on the VNet and the Automation account traverses over the a private link, eliminating exposure from the public internet.', recommended_action: 'Modify automation account and disable public access.', link: 'https://learn.microsoft.com/en-us/azure/automation/how-to/private-link-security', - apis: ['automationAccounts:list'], + apis: ['automationAccounts:list', 'automationAccounts:get'], realtime_triggers: ['microsoftautomation:automationaccounts:write','microsoftautomation:automationaccounts:delete'], run: function(cache, settings, callback) { @@ -38,8 +38,20 @@ module.exports = { for (var account of automationAccounts.data) { if (!account.id) continue; - if (!account.publicNetworkAccess) { - helpers.addResult(results, 0, 'Automation account has public network access disabled', location, account.id); + var describeAcct = helpers.addSource(cache, source, + ['automationAccounts', 'get', location, account.id]); + + if (!describeAcct || describeAcct.err || !describeAcct.data ) { + helpers.addResult(results, 3, 'Unable to query for Automation account: ' + helpers.addError(describeAcct), location); + continue; + } + + if (Object.prototype.hasOwnProperty.call(describeAcct.data, 'publicNetworkAccess')) { + if (describeAcct.data.publicNetworkAccess) { + helpers.addResult(results, 2, 'Automation account does not have public network access disabled', location, account.id); + } else { + helpers.addResult(results, 0, 'Automation account has public network access disabled', location, account.id); + } } else { helpers.addResult(results, 2, 'Automation account does not have public network access disabled', location, account.id); } diff --git a/plugins/azure/automationAccounts/automationAcctPublicAccess.spec.js b/plugins/azure/automationAccounts/automationAcctPublicAccess.spec.js index 80472b7957..5d4ce4e11e 100644 --- a/plugins/azure/automationAccounts/automationAcctPublicAccess.spec.js +++ b/plugins/azure/automationAccounts/automationAcctPublicAccess.spec.js @@ -2,6 +2,12 @@ var expect = require('chai').expect; var automationAcctPublicAccess = require('./automationAcctPublicAccess.js'); const automationAccounts = [ + { + "id": "/subscriptions/12345/resourceGroups/DefaultResourceGroup-EUS/providers/Microsoft.Automation/automationAccounts/Automate-12345-EUS2" + } +]; + +const account = [ { "id": "/subscriptions/12345/resourceGroups/DefaultResourceGroup-EUS/providers/Microsoft.Automation/automationAccounts/Automate-12345-EUS2", "location": "EastUS2", @@ -22,7 +28,6 @@ const automationAccounts = [ } }, "publicNetworkAccess": false, - }, { "id": "/subscriptions/12345/resourceGroups/DefaultResourceGroup-CUS/providers/Microsoft.Automation/automationAccounts/Automate-12345-CUS", @@ -34,17 +39,29 @@ const automationAccounts = [ } ]; -const createCache = (automationAccounts,err) => { +const createCache = (automationAccounts, acct) => { + let automationacct = {}; + let getacct = {}; + + if (automationAccounts) { + automationacct['data'] = automationAccounts; + if (automationAccounts && automationAccounts.length) { + getacct[automationAccounts[0].id] = { + 'data': acct + }; + } + } + return { automationAccounts: { list: { - 'eastus': { - data: automationAccounts, - err: err - } + 'eastus': automationacct + }, + get: { + 'eastus': getacct } } - } + }; }; describe('automationAcctPublicAccess', function () { @@ -62,7 +79,7 @@ describe('automationAcctPublicAccess', function () { }); it('should give unknown result if Unable to query automation accounts:', function (done) { - const cache = createCache(null, 'Error'); + const cache = createCache(); automationAcctPublicAccess.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(3); @@ -73,7 +90,7 @@ describe('automationAcctPublicAccess', function () { }); it('should give passing result if automation account has public network access disabled', function (done) { - const cache = createCache([automationAccounts[0]]); + const cache = createCache(automationAccounts, account[0]); automationAcctPublicAccess.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); @@ -84,7 +101,7 @@ describe('automationAcctPublicAccess', function () { }); it('should give failing result if automation account does not have public network access disabled', function (done) { - const cache = createCache([automationAccounts[1]]); + const cache = createCache(automationAccounts, account[1]); automationAcctPublicAccess.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); diff --git a/plugins/azure/batchAccounts/batchAccountsAADEnabled.js b/plugins/azure/batchAccounts/batchAccountsAADEnabled.js index cf9cabc3ad..fc2839ecd2 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 cd106bc09f..508c96d692 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 767c81eb90..4b0821e755 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 6fb376db77..978ecbd018 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 a5396cec29..32c72d9306 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'], diff --git a/plugins/azure/cosmosdb/cosmosdbLocalAuth.js b/plugins/azure/cosmosdb/cosmosdbLocalAuth.js index 8efe7ca7b6..7090696b5c 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.js b/plugins/azure/cosmosdb/cosmosdbManagedIdentity.js index d5c9b2519e..a23d70cb92 100644 --- a/plugins/azure/cosmosdb/cosmosdbManagedIdentity.js +++ b/plugins/azure/cosmosdb/cosmosdbManagedIdentity.js @@ -7,7 +7,7 @@ module.exports = { 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 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/cosmos-db/managed-identity-based-authentication', recommended_action: 'Enable system or user-assigned identities for all Azure Cosmos DB accounts.', apis: ['databaseAccounts:list'], diff --git a/plugins/azure/defender/enableDefenderForSqlServers.js b/plugins/azure/defender/enableDefenderForSqlServers.js index a5809de370..b529d9665f 100644 --- a/plugins/azure/defender/enableDefenderForSqlServers.js +++ b/plugins/azure/defender/enableDefenderForSqlServers.js @@ -6,39 +6,94 @@ module.exports = { category: 'Defender', domain: 'Management and Governance', severity: 'High', - description: 'Ensures that Microsoft Defender is enabled for Azure SQL Server Databases.', + description: 'Ensures that Microsoft Defender is enabled for Azure SQL Server Databases at subscription level or individual resource level.', more_info: 'Turning on Microsoft Defender for Azure SQL Server Databases enables threat detection for Azure SQL database servers, providing threat intelligence, anomaly detection, and behavior analytics in the Microsoft Defender for Cloud.', recommended_action: 'Turning on Microsoft Defender for Azure SQL Databases incurs an additional cost per resource.', link: 'https://learn.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities', - apis: ['pricings:list'], - realtime_triggers: ['microsoftsecurity:pricings:write','microsoftsecurity:pricings:delete'], + apis: ['pricings:list', 'servers:listSql', 'serverSecurityAlertPolicies:listByServer'], + settings: { + check_level: { + name: 'Defender Check Level', + description: 'Check for Defender at subscription level or resource level', + regex: '^(subscription|resource)$', + default: 'subscription' + } + }, + realtime_triggers: ['microsoftsecurity:pricings:write','microsoftsecurity:pricings:delete','microsoftsql:servers:securityalertpolicies:write'], run: function(cache, settings, callback) { var results = []; var source = {}; var locations = helpers.locations(settings.govcloud); - async.each(locations.pricings, function(location, rcb) { - var pricings = helpers.addSource(cache, source, - ['pricings', 'list', location]); + var config = { + check_level: settings.check_level || this.settings.check_level.default + }; - if (!pricings) return rcb(); + var serviceName = 'sqlservers'; + var serviceDisplayName = 'SQL Servers'; + + + if (config.check_level === 'subscription') { + var pricings = helpers.addSource(cache, source, ['pricings', 'list', 'global']); + + if (!pricings) return callback(null, results, source); if (pricings.err || !pricings.data) { helpers.addResult(results, 3, - 'Unable to query for Pricing: ' + helpers.addError(pricings), location); - return rcb(); + 'Unable to query Pricing information: ' + helpers.addError(pricings), 'global'); + return callback(null, results, source); } if (!pricings.data.length) { - helpers.addResult(results, 0, 'No Pricing information found', location); + helpers.addResult(results, 0, 'No Pricing information found', 'global'); + return callback(null, results, source); + } + + helpers.checkMicrosoftDefender(pricings, serviceName, serviceDisplayName, results, 'global'); + return callback(null, results, source); + } + + async.each(locations.servers, function(location, rcb) { + const servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); return rcb(); } - helpers.checkMicrosoftDefender(pricings, 'sqlservers', 'SQL Server Databases', results, location); + servers.data.forEach(server => { + const securitySettings = helpers.addSource(cache, source, + ['serverSecurityAlertPolicies', 'listByServer', location, server.id]); + + if (!securitySettings || securitySettings.err || !securitySettings.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL server security alert policies: ' + helpers.addError(securitySettings), + location, server.id); + } else { + securitySettings.data.forEach(setting => { + if (setting.state && setting.state.toLowerCase() === 'enabled') { + helpers.addResult(results, 0, + 'Azure Defender is enabled for SQL server', location, server.id); + } else { + helpers.addResult(results, 2, + 'Azure Defender is not enabled for SQL server', location, server.id); + } + }); + } + }); rcb(); - }, function(){ + }, function() { callback(null, results, source); }); } diff --git a/plugins/azure/defender/enableDefenderForSqlServers.spec.js b/plugins/azure/defender/enableDefenderForSqlServers.spec.js index 26a315964e..fd4808c7b7 100644 --- a/plugins/azure/defender/enableDefenderForSqlServers.spec.js +++ b/plugins/azure/defender/enableDefenderForSqlServers.spec.js @@ -1,87 +1,127 @@ -var assert = require('assert'); -var expect = require('chai').expect; -var auth = require('./enableDefenderForSqlServers'); +const assert = require('assert'); +const expect = require('chai').expect; +const plugin = require('./enableDefenderForSqlServers'); -const createCache = (err, data) => { - return { - pricings: { - list: { - 'global': { - err: err, - data: data - } - } - } - } -}; - -describe('enableDefenderForSqlDatabases', function() { +describe('enableDefenderForSqlServers', function() { describe('run', function() { - it('should give passing result if no pricings 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 Pricing information found'); - expect(results[0].region).to.equal('global'); - done() + it('should give passing result if Defender is enabled at subscription level', function(done) { + const cache = createCache([{ + id: '/subscriptions/123/providers/Microsoft.Security/pricings/SqlServers', + name: 'SqlServers', + pricingTier: 'Standard' + }]); + const settings = { + check_level: 'subscription' }; - const cache = createCache( - null, - [] - ); + plugin.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Azure Defender is enabled for SQL Servers'); + done(); + }); + }); - auth.run(cache, {}, callback); - }); + it('should give failing result if Defender is not enabled at subscription level', function(done) { + const cache = createCache([{ + id: '/subscriptions/123/providers/Microsoft.Security/pricings/SqlServers', + name: 'SqlServers', + pricingTier: 'Free' + }]); + const settings = { + check_level: 'subscription' + }; - it('should give failing result if Azure Defender for SQL Server Databases is not enabled', function(done) { - const callback = (err, results) => { + plugin.run(cache, settings, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Azure Defender is not enabled for SQL Server Databases'); - expect(results[0].region).to.equal('global'); - done() - }; + expect(results[0].message).to.include('Azure Defender is not enabled for SQL Servers'); + done(); + }); + }); + it('should give passing result if Defender is enabled at resource level', function(done) { const cache = createCache( - null, - [ - { - "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/providers/Microsoft.Security/pricings/default", - "name": "SqlServers", - "type": "Microsoft.Security/pricings", - "pricingTier": "free", - "location": "global" - } - ] + [{ + id: '/subscriptions/123/providers/Microsoft.Security/pricings/SqlServers', + name: 'SqlServers', + pricingTier: 'Free' + }], + [{ + id: '/subscriptions/123/servers/test-server' + }], + [{ + id: '/subscriptions/123/servers/test-server/security', + state: 'Enabled' + }] ); + const settings = { + check_level: 'resource' + }; - auth.run(cache, {}, callback); - }); - - it('should give passing result if Azure Defender for SQL Server Databases is enabled', function(done) { - const callback = (err, results) => { + plugin.run(cache, settings, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Azure Defender is enabled for SQL Server Databases'); - expect(results[0].region).to.equal('global'); - done() - }; + expect(results[0].message).to.include('Azure Defender is enabled for SQL server'); + done(); + }); + }); + it('should give failing result if Defender is not enabled at resource level', function(done) { const cache = createCache( - null, - [ - { - "id": "/subscriptions/e79d9a03-3ab3-4481-bdcd-c5db1d55420a/providers/Microsoft.Security/pricings/default", - "name": "SqlServers", - "type": "Microsoft.Security/pricings", - "pricingTier": "Standard", - "location": "global" - } - ] + [{ + id: '/subscriptions/123/providers/Microsoft.Security/pricings/SqlServers', + name: 'SqlServers', + pricingTier: 'Free' + }], + [{ + id: '/subscriptions/123/servers/test-server' + }], + [{ + id: '/subscriptions/123/servers/test-server/security', + state: 'Disabled' + }] ); + const settings = { + check_level: 'resource' + }; + + plugin.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Azure Defender is not enabled for SQL server'); + done(); + }); + }); - auth.run(cache, {}, callback); - }) - }) -}); \ No newline at end of file + // Add other necessary test cases for error conditions + }); +}); + +function createCache(pricingData, serversData, securityData) { + return { + pricings: { + list: { + global: { + data: pricingData + } + } + }, + servers: { + listSql: { + 'eastus': { + data: serversData + } + } + }, + serverSecurityAlertPolicies: { + listByServer: { + 'eastus': { + '/subscriptions/123/servers/test-server': { + data: securityData + } + } + } + } + }; +} \ No newline at end of file diff --git a/plugins/azure/activedirectory/appOrgnaizationalDirectoryAccess.js b/plugins/azure/entraid/appOrgnaizationalDirectoryAccess.js similarity index 58% rename from plugins/azure/activedirectory/appOrgnaizationalDirectoryAccess.js rename to plugins/azure/entraid/appOrgnaizationalDirectoryAccess.js index c0c9f6afaf..614cea0ce3 100644 --- a/plugins/azure/activedirectory/appOrgnaizationalDirectoryAccess.js +++ b/plugins/azure/entraid/appOrgnaizationalDirectoryAccess.js @@ -2,13 +2,13 @@ const async = require('async'); const helpers = require('../../../helpers/azure'); module.exports = { - title: 'Azure AD App Organizational Directory Access', - category: 'Active Directory', + title: 'Azure Entra ID App Organizational Directory Access', + category: 'Entra ID', domain: 'Identity and Access Management', severity: 'Medium', - description: 'Ensures that Azure Active Directory applications are accessible to accounts in organisational directory only.', - more_info: 'AAD provides different types of account access. By using single-tenant authentication, the impact gets limited to the application’s tenant i.e. all users from the same tenant could connect to the application and save app from unauthorised access.', - link: 'https://learn.microsoft.com/en-us/azure/active-directory/develop/single-and-multi-tenant-apps', + description: 'Ensures that Azure Entra Id applications are accessible to accounts in organisational directory only.', + more_info: 'Entra ID provides different types of account access. By using single-tenant authentication, the impact gets limited to the application’s tenant i.e. all users from the same tenant could connect to the application and save app from unauthorised access.', + link: 'https://learn.microsoft.com/en-us/entra/identity-platform/single-and-multi-tenant-apps', recommended_action: 'Modify the Azure app authentication setting and provide access to accounts in organisational directory only', apis: ['applications:list'], @@ -23,20 +23,20 @@ module.exports = { if (!applications) return rcb(); if (applications.err || !applications.data) { - helpers.addResult(results, 3, 'Unable to query for AAD applications: ' + helpers.addError(applications), location); + helpers.addResult(results, 3, 'Unable to query for Entra ID applications: ' + helpers.addError(applications), location); return rcb(); } if (!applications.data.length) { - helpers.addResult(results, 0, 'No existing AAD applications found', location); + helpers.addResult(results, 0, 'No existing Entra ID applications found', location); return rcb(); } for (let app of applications.data) { if (!app.appId) continue; if (app.signInAudience && app.signInAudience === 'AzureADMultipleOrgs' || app.signInAudience === 'AzureADandPersonalMicrosoftAccount'){ - helpers.addResult(results, 2, 'AAD application has multi-tenant access enabled', location, app.appId); + helpers.addResult(results, 2, 'Entra ID application has multi-tenant access enabled', location, app.appId); } else { - helpers.addResult(results, 0, 'AAD application has single-tenant access enabled', location, app.appId); + helpers.addResult(results, 0, 'Entra ID application has single-tenant access enabled', location, app.appId); } } rcb(); diff --git a/plugins/azure/activedirectory/appOrgnaizationalDirectoryAccess.spec.js b/plugins/azure/entraid/appOrgnaizationalDirectoryAccess.spec.js similarity index 92% rename from plugins/azure/activedirectory/appOrgnaizationalDirectoryAccess.spec.js rename to plugins/azure/entraid/appOrgnaizationalDirectoryAccess.spec.js index b6e22d1640..86a92ed8d0 100644 --- a/plugins/azure/activedirectory/appOrgnaizationalDirectoryAccess.spec.js +++ b/plugins/azure/entraid/appOrgnaizationalDirectoryAccess.spec.js @@ -48,7 +48,7 @@ describe('appOrgnaizationalDirectoryAccess', function() { const callback = (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('No existing AAD applications found'); + expect(results[0].message).to.include('No existing Entra ID applications found'); expect(results[0].region).to.equal('global'); done() }; @@ -64,7 +64,7 @@ describe('appOrgnaizationalDirectoryAccess', function() { 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 AAD applications:'); + expect(results[0].message).to.include('Unable to query for Entra ID applications:'); expect(results[0].region).to.equal('global'); done() }; @@ -81,7 +81,7 @@ describe('appOrgnaizationalDirectoryAccess', function() { const callback = (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('AAD application has multi-tenant access enabled'); + expect(results[0].message).to.include('Entra ID application has multi-tenant access enabled'); expect(results[0].region).to.equal('global'); done() }; @@ -95,7 +95,7 @@ describe('appOrgnaizationalDirectoryAccess', function() { const callback = (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('AAD application has single-tenant access enabled'); + expect(results[0].message).to.include('Entra ID application has single-tenant access enabled'); expect(results[0].region).to.equal('global'); done() }; diff --git a/plugins/azure/activedirectory/ensureNoGuestUser.js b/plugins/azure/entraid/ensureNoGuestUser.js similarity index 91% rename from plugins/azure/activedirectory/ensureNoGuestUser.js rename to plugins/azure/entraid/ensureNoGuestUser.js index 13afc564e4..5116192d1f 100644 --- a/plugins/azure/activedirectory/ensureNoGuestUser.js +++ b/plugins/azure/entraid/ensureNoGuestUser.js @@ -3,13 +3,13 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Ensure No Guest User', - category: 'Active Directory', + category: 'Entra ID', domain: 'Identity and Access Management', severity: 'Medium', description: 'Ensures that there are no guest users in the subscription', more_info: 'Guest users are usually users that are invited from outside the company structure, these users are not part of the onboarding/offboarding process and could be overlooked, causing security vulnerabilities.', - link: 'https://learn.microsoft.com/en-us/azure/active-directory/b2b/add-users-administrator', - recommended_action: 'Remove all guest users unless they are required to be members of the Active Directory account.', + link: 'https://learn.microsoft.com/en-us/entra/external-id/add-users-administrator', + recommended_action: 'Remove all guest users unless they are required to be members of the Entra ID tenant.', apis: ['users:list'], run: function(cache, settings, callback) { diff --git a/plugins/azure/activedirectory/ensureNoGuestUser.spec.js b/plugins/azure/entraid/ensureNoGuestUser.spec.js similarity index 100% rename from plugins/azure/activedirectory/ensureNoGuestUser.spec.js rename to plugins/azure/entraid/ensureNoGuestUser.spec.js diff --git a/plugins/azure/activedirectory/minPasswordLength.js b/plugins/azure/entraid/minPasswordLength.js similarity index 87% rename from plugins/azure/activedirectory/minPasswordLength.js rename to plugins/azure/entraid/minPasswordLength.js index db9f98d394..b02b799ae3 100644 --- a/plugins/azure/activedirectory/minPasswordLength.js +++ b/plugins/azure/entraid/minPasswordLength.js @@ -3,12 +3,12 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Minimum Password Length', - category: 'Active Directory', + category: 'Entra ID', domain: 'Identity and Access Management', severity: 'Low', description: 'Ensures that all Azure passwords require a minimum length', more_info: 'Azure handles most password policy settings, including the minimum password length, defaulted to 8 characters.', - link: 'https://learn.microsoft.com/en-us/azure/active-directory/authentication/concept-sspr-policy#password-policies-that-only-apply-to-cloud-user-accounts', + link: 'https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sspr-policy#password-policies-that-only-apply-to-cloud-user-accounts', recommended_action: 'No action necessary. Azure handles password requirement settings.', apis: ['resources:list'], diff --git a/plugins/azure/activedirectory/noCustomOwnerRoles.js b/plugins/azure/entraid/noCustomOwnerRoles.js similarity index 99% rename from plugins/azure/activedirectory/noCustomOwnerRoles.js rename to plugins/azure/entraid/noCustomOwnerRoles.js index 5c7962b7e3..a6c6a0cf6f 100644 --- a/plugins/azure/activedirectory/noCustomOwnerRoles.js +++ b/plugins/azure/entraid/noCustomOwnerRoles.js @@ -3,7 +3,7 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'No Custom Owner Roles', - category: 'Active Directory', + category: 'Entra ID', domain: 'Identity and Access Management', severity: 'Medium', description: 'Ensures that no custom owner roles exist.', diff --git a/plugins/azure/activedirectory/noCustomOwnerRoles.spec.js b/plugins/azure/entraid/noCustomOwnerRoles.spec.js similarity index 100% rename from plugins/azure/activedirectory/noCustomOwnerRoles.spec.js rename to plugins/azure/entraid/noCustomOwnerRoles.spec.js diff --git a/plugins/azure/activedirectory/passwordRequiresLowercase.js b/plugins/azure/entraid/passwordRequiresLowercase.js similarity index 88% rename from plugins/azure/activedirectory/passwordRequiresLowercase.js rename to plugins/azure/entraid/passwordRequiresLowercase.js index be1f298bf9..bfa1183dd8 100644 --- a/plugins/azure/activedirectory/passwordRequiresLowercase.js +++ b/plugins/azure/entraid/passwordRequiresLowercase.js @@ -3,12 +3,12 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Password Requires Lowercase', - category: 'Active Directory', + category: 'Entra ID', domain: 'Identity and Access Management', severity: 'Low', description: 'Ensures that all Azure passwords require lowercase characters', more_info: 'Azure handles most password policy settings, including which character types are required. Azure requires 3 out of 4 of the following character types: lowercase, uppercase, special characters, and numbers.', - link: 'https://learn.microsoft.com/en-us/azure/active-directory/authentication/concept-sspr-policy#password-policies-that-only-apply-to-cloud-user-accounts', + link: 'https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sspr-policy#password-policies-that-only-apply-to-cloud-user-accounts', recommended_action: 'No action necessary. Azure handles password requirement settings.', apis: ['resources:list'], diff --git a/plugins/azure/activedirectory/passwordRequiresNumbers.js b/plugins/azure/entraid/passwordRequiresNumbers.js similarity index 88% rename from plugins/azure/activedirectory/passwordRequiresNumbers.js rename to plugins/azure/entraid/passwordRequiresNumbers.js index 3606f5d448..d153673854 100644 --- a/plugins/azure/activedirectory/passwordRequiresNumbers.js +++ b/plugins/azure/entraid/passwordRequiresNumbers.js @@ -3,12 +3,12 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Password Requires Numbers', - category: 'Active Directory', + category: 'Entra ID', domain: 'Identity and Access Management', severity: 'Low', description: 'Ensures that all Azure passwords require numbers', more_info: 'Azure handles most password policy settings, including which character types are required. Azure requires 3 out of 4 of the following character types: lowercase, uppercase, special characters, and numbers.', - link: 'https://learn.microsoft.com/en-us/azure/active-directory/authentication/concept-sspr-policy#password-policies-that-only-apply-to-cloud-user-accounts', + link: 'https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sspr-policy#password-policies-that-only-apply-to-cloud-user-accounts', recommended_action: 'No action necessary. Azure handles password requirement settings.', apis: ['resources:list'], diff --git a/plugins/azure/activedirectory/passwordRequiresSymbols.js b/plugins/azure/entraid/passwordRequiresSymbols.js similarity index 88% rename from plugins/azure/activedirectory/passwordRequiresSymbols.js rename to plugins/azure/entraid/passwordRequiresSymbols.js index 91ea294532..a35a8e33c9 100644 --- a/plugins/azure/activedirectory/passwordRequiresSymbols.js +++ b/plugins/azure/entraid/passwordRequiresSymbols.js @@ -3,12 +3,12 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Password Requires Symbols', - category: 'Active Directory', + category: 'Entra ID', domain: 'Identity and Access Management', severity: 'Low', description: 'Ensures that all Azure passwords require symbol characters', more_info: 'Azure handles most password policy settings, including which character types are required. Azure requires 3 out of 4 of the following character types: lowercase, uppercase, special characters, and numbers.', - link: 'https://learn.microsoft.com/en-us/azure/active-directory/authentication/concept-sspr-policy#password-policies-that-only-apply-to-cloud-user-accounts', + link: 'https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sspr-policy#password-policies-that-only-apply-to-cloud-user-accounts', recommended_action: 'No action necessary. Azure handles password requirement settings.', apis: ['resources:list'], diff --git a/plugins/azure/activedirectory/passwordRequiresUppercase.js b/plugins/azure/entraid/passwordRequiresUppercase.js similarity index 88% rename from plugins/azure/activedirectory/passwordRequiresUppercase.js rename to plugins/azure/entraid/passwordRequiresUppercase.js index 5dd2b90e4e..c5c4f71fdd 100644 --- a/plugins/azure/activedirectory/passwordRequiresUppercase.js +++ b/plugins/azure/entraid/passwordRequiresUppercase.js @@ -3,12 +3,12 @@ const helpers = require('../../../helpers/azure'); module.exports = { title: 'Password Requires Uppercase', - category: 'Active Directory', + category: 'Entra ID', domain: 'Identity and Access Management', severity: 'Low', description: 'Ensures that all Azure passwords require uppercase characters', more_info: 'Azure handles most password policy settings, including which character types are required. Azure requires 3 out of 4 of the following character types: lowercase, uppercase, special characters, and numbers.', - link: 'https://learn.microsoft.com/en-us/azure/active-directory/authentication/concept-sspr-policy#password-policies-that-only-apply-to-cloud-user-accounts', + link: 'https://learn.microsoft.com/en-us/entra/identity/authentication/concept-sspr-policy#password-policies-that-only-apply-to-cloud-user-accounts', recommended_action: 'No action necessary. Azure handles password requirement settings.', apis: ['resources:list'], diff --git a/plugins/azure/eventGrid/domainLocalAuthDisabled.js b/plugins/azure/eventGrid/domainLocalAuthDisabled.js index 739e1053f0..e065a902d6 100644 --- a/plugins/azure/eventGrid/domainLocalAuthDisabled.js +++ b/plugins/azure/eventGrid/domainLocalAuthDisabled.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Management and Governance', severity: 'Low', description: 'Ensures that local authentication is disabled for Event Grid domains.', - 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 (shared access policies) for Azure Event Grid.', + 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 (shared access policies) for Azure Event Grid.', recommended_action: 'Ensure that Event Grid domains have local authentication disabled.', link: 'https://learn.microsoft.com/en-us/azure/event-grid/authenticate-with-microsoft-entra-id#disable-key-and-shared-access-signature-authentication', apis: ['eventGrid:listDomains'], diff --git a/plugins/azure/eventGrid/domainManagedIdentity.js b/plugins/azure/eventGrid/domainManagedIdentity.js index b50ca3910c..29bbb2f347 100644 --- a/plugins/azure/eventGrid/domainManagedIdentity.js +++ b/plugins/azure/eventGrid/domainManagedIdentity.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Management and Governance', severity: 'Medium', description: 'Ensure that Event Grid domains 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 Event Grid domains and enable managed identity.', link: 'https://learn.microsoft.com/en-us/azure/event-grid/managed-service-identity', apis: ['eventGrid:listDomains'], diff --git a/plugins/azure/eventhub/eventHubLocalAuthDisabled.js b/plugins/azure/eventhub/eventHubLocalAuthDisabled.js index b3c01abd0f..cc3d8586d9 100644 --- a/plugins/azure/eventhub/eventHubLocalAuthDisabled.js +++ b/plugins/azure/eventhub/eventHubLocalAuthDisabled.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Content Delivery', severity: 'Low', description: 'Ensures local authentication is disabled for Event Hub namespace.', - 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 in Azure Event Hubs namespaces.', + 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 in Azure Event Hubs namespaces.', recommended_action: 'Ensure that Azure Event Hubs namespaces have local authentication disabled.', link: 'https://learn.microsoft.com/en-us/azure/event-hubs/authenticate-shared-access-signature#disabling-localsas-key-authentication', apis: ['eventHub:listEventHub'], diff --git a/plugins/azure/eventhub/eventHubManagedIdentity.js b/plugins/azure/eventhub/eventHubManagedIdentity.js index 0e6fb6b420..cde653721d 100644 --- a/plugins/azure/eventhub/eventHubManagedIdentity.js +++ b/plugins/azure/eventhub/eventHubManagedIdentity.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Content Delivery', severity: 'Medium', description: 'Ensures Microsoft Azure Event Hubs namespaces 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 Event Hubs namespace and enable managed identity.', link: 'https://learn.microsoft.com/en-us/azure/event-hubs/authenticate-managed-identity', apis: ['eventHub:listEventHub'], diff --git a/plugins/azure/eventhub/eventHubMinimumTLSversion.js b/plugins/azure/eventhub/eventHubMinimumTLSversion.js index dbba63485e..8e10765a16 100644 --- a/plugins/azure/eventhub/eventHubMinimumTLSversion.js +++ b/plugins/azure/eventhub/eventHubMinimumTLSversion.js @@ -11,14 +11,6 @@ module.exports = { recommended_action: 'Modify Event Hubs namespaces to set the desired minimum TLS version.', link: 'https://learn.microsoft.com/en-us/azure/event-hubs/transport-layer-security-enforce-minimum-version', apis: ['eventHub:listEventHub'], - settings: { - event_hub_min_tls_version: { - name: 'Event Hub Minimum TLS Version', - description: 'Minimum desired TLS version for Microsoft Azure Event Hubs', - regex: '^(1.0|1.1|1.2)$', - default: '1.2' - } - }, realtime_triggers: ['microsofteventhub:namespaces:write', 'microsofteventhub:namespaces:delete'], run: function(cache, settings, callback) { @@ -26,11 +18,9 @@ module.exports = { var source = {}; var locations = helpers.locations(settings.govcloud); - var config = { - event_hub_min_tls_version: settings.event_hub_min_tls_version || this.settings.event_hub_min_tls_version.default - }; + var event_hub_min_tls_version = '1.2'; - var desiredVersion = parseFloat(config.event_hub_min_tls_version); + var desiredVersion = parseFloat(event_hub_min_tls_version); async.each(locations.eventHub, function(location, rcb) { var eventHubs = helpers.addSource(cache, source, @@ -58,7 +48,7 @@ module.exports = { location, eventHub.id); } else { helpers.addResult(results, 2, - `Event Hubs namespace is using TLS version ${eventHub.minimumTlsVersion} instead of version ${config.event_hub_min_tls_version}`, + `Event Hubs namespace is using TLS version ${eventHub.minimumTlsVersion} instead of version ${event_hub_min_tls_version}`, location, eventHub.id); } } diff --git a/plugins/azure/eventhub/eventHubPublicAccess.js b/plugins/azure/eventhub/eventHubPublicAccess.js index 7f8f56e3eb..7b0717b8f2 100644 --- a/plugins/azure/eventhub/eventHubPublicAccess.js +++ b/plugins/azure/eventhub/eventHubPublicAccess.js @@ -10,13 +10,23 @@ module.exports = { more_info: 'Configuring Azure Event Hubs namespace with public access poses a security risk. To mitigate this risk, it is advisable to limit access by allowing connections only from specific IP addresses or private networks.', recommended_action: 'Ensure that public network access is disabled for each Event Hubs namespace.', link: 'https://learn.microsoft.com/en-us/azure/event-hubs/event-hubs-ip-filtering#configure-public-access-when-creating-a-namespace', - apis: ['eventHub:listEventHub'], + apis: ['eventHub:listEventHub', 'eventHub:listNetworkRuleSet'], realtime_triggers: ['microsofteventhub:namespaces:write', 'microsofteventhub:namespaces:delete', 'microsofteventhub:namespaces:networkrulesets:write'], - + settings: { + check_selected_networks: { + name: 'Evaluate Selected Networks', + description: 'Checks if specific IP addresses or virtual networks are set to restrict Event Hub access.', + regex: '^(true|false)$', + default: false, + } + }, run: function(cache, settings, callback) { var results = []; var source = {}; var locations = helpers.locations(settings.govcloud); + let config = { + check_selected_networks: settings.check_selected_networks || this.settings.check_selected_networks.default + }; async.each(locations.eventHub, function(location, rcb) { var eventHubs = helpers.addSource(cache, source, @@ -35,16 +45,33 @@ module.exports = { return rcb(); } - for (let eventHub of eventHubs.data){ + for (let eventHub of eventHubs.data) { if (!eventHub.id) continue; - if (eventHub.sku && eventHub.sku.tier && eventHub.sku.tier.toLowerCase() === 'basic') { + if (eventHub.sku && eventHub.sku.tier && eventHub.sku.tier.toLowerCase() === 'basic') { helpers.addResult(results, 0, 'Event Hubs namespace tier is basic', location, eventHub.id); } else { if (eventHub.publicNetworkAccess && eventHub.publicNetworkAccess.toLowerCase() === 'enabled') { - helpers.addResult(results, 2, - 'Event Hubs namespace is publicly accessible',location, eventHub.id); + if (config.check_selected_networks) { + const listNetworkRuleSet = helpers.addSource(cache, source, + ['eventHub', 'listNetworkRuleSet', location, eventHub.id]); + if (!listNetworkRuleSet || listNetworkRuleSet.err || !listNetworkRuleSet.data) { + helpers.addResult(results, 3, + 'Unable to query Event Hubs network rule set: ' + helpers.addError(listNetworkRuleSet), location, eventHub.id); + continue; + } + if ((listNetworkRuleSet.data.ipRules && listNetworkRuleSet.data.ipRules.length > 0) || (listNetworkRuleSet.data.virtualNetworkRules && listNetworkRuleSet.data.virtualNetworkRules.length > 0)) { + helpers.addResult(results, 0, + 'Event Hubs namespace is not publicly accessible', location, eventHub.id); + } else { + helpers.addResult(results, 2, + 'Event Hubs namespace is publicly accessible', location, eventHub.id); + } + } else { + helpers.addResult(results, 2, + 'Event Hubs namespace is publicly accessible', location, eventHub.id); + } } else { helpers.addResult(results, 0, 'Event Hubs namespace is not publicly accessible', location, eventHub.id); diff --git a/plugins/azure/eventhub/eventHubPublicAccess.spec.js b/plugins/azure/eventhub/eventHubPublicAccess.spec.js index d70e8c55bc..0043cddfbe 100644 --- a/plugins/azure/eventhub/eventHubPublicAccess.spec.js +++ b/plugins/azure/eventhub/eventHubPublicAccess.spec.js @@ -3,139 +3,350 @@ var eventHubPublicAccess = require('./eventHubPublicAccess'); const eventHubs = [ { - "kind": "v12.0", - "location": "eastus", - "tags": {}, - "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub'", - "name": "testHub", - "type": 'Microsoft.EventHub/Namespaces', - "location": 'East US', - "tags": {}, - "minimumTlsVersion": '1.2', - "publicNetworkAccess": 'Disabled', - "disableLocalAuth": true, - "zoneRedundant": true, - "isAutoInflateEnabled": false, - "maximumThroughputUnits": 0, - "kafkaEnabled": false + kind: "v12.0", + location: "eastus", + tags: {}, + id: "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub", + name: "testHub", + type: "Microsoft.EventHub/Namespaces", + tags: {}, + minimumTlsVersion: "1.2", + publicNetworkAccess: "Disabled", + disableLocalAuth: true, + zoneRedundant: true, + isAutoInflateEnabled: false, + maximumThroughputUnits: 0, + kafkaEnabled: false, }, - { - "kind": "v12.0", - "location": "eastus", - "tags": {}, - "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub'", - "name": "testHub", - "type": 'Microsoft.EventHub/Namespaces', - "location": 'East US', - "tags": {}, - "minimumTlsVersion": '1.1', - "publicNetworkAccess": 'Enabled', - "disableLocalAuth": true, - "zoneRedundant": true, - "isAutoInflateEnabled": false, - "maximumThroughputUnits": 0, - "kafkaEnabled": false, + { + kind: "v12.0", + location: "eastus", + tags: {}, + id: "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub", + name: "testHub", + type: "Microsoft.EventHub/Namespaces", + tags: {}, + minimumTlsVersion: "1.1", + publicNetworkAccess: "Enabled", + disableLocalAuth: true, + zoneRedundant: true, + isAutoInflateEnabled: false, + maximumThroughputUnits: 0, + kafkaEnabled: false, }, { - "kind": "v12.0", - "location": "eastus", - "tags": {}, - "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub'", - "name": "testHub2", - "type": 'Microsoft.EventHub/Namespaces', - "location": 'East US', - "tags": {}, - "sku": { - "name": "Basic", - "tier": "Basic", - "capacity": 1 + kind: "v12.0", + location: "eastus", + tags: {}, + id: "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub", + name: "testHub2", + type: "Microsoft.EventHub/Namespaces", + tags: {}, + sku: { + name: "Basic", + tier: "Basic", + capacity: 1, }, - "minimumTlsVersion": '1.2', - "publicNetworkAccess": 'Enabled', - "disableLocalAuth": true, - "isAutoInflateEnabled": false, - "maximumThroughputUnits": 0, - "kafkaEnabled": false + minimumTlsVersion: "1.2", + publicNetworkAccess: "Enabled", + disableLocalAuth: true, + isAutoInflateEnabled: false, + maximumThroughputUnits: 0, + kafkaEnabled: false, + }, + { + kind: "v12.0", + location: "eastus", + tags: {}, + id: "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub", + name: "testHub", + type: "Microsoft.EventHub/Namespaces", + tags: {}, + minimumTlsVersion: "1.2", + publicNetworkAccess: "Enabled", + disableLocalAuth: true, + zoneRedundant: true, + isAutoInflateEnabled: false, + maximumThroughputUnits: 0, + kafkaEnabled: false, + }, + { + kind: "v12.0", + location: "eastus", + tags: {}, + name: "testHub4", + type: "Microsoft.EventHub/Namespaces", + tags: {}, + minimumTlsVersion: "1.2", + publicNetworkAccess: "Enabled", + disableLocalAuth: true, + zoneRedundant: true, + isAutoInflateEnabled: false, + maximumThroughputUnits: 0, + kafkaEnabled: false, + }, + { + kind: "v12.0", + location: "eastus", + tags: {}, + id: "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub", + name: "testHub", + type: "Microsoft.EventHub/Namespaces", + tags: {}, + minimumTlsVersion: "1.2", + publicNetworkAccess: "Enabled", + disableLocalAuth: true, + zoneRedundant: true, + isAutoInflateEnabled: false, + maximumThroughputUnits: 0, + kafkaEnabled: false, + }, +]; + +const networkRuleSets = [ + { + id: "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub/networkrulesets/default", + name: "default", + type: "Microsoft.EventHub/Namespaces/NetworkRuleSets", + location: "eastus", + publicNetworkAccess: "Enabled", + defaultAction: "Allow", + virtualNetworkRules: [], + ipRules: [ + { + ipMask: "192.168.1.0/24", + action: "Allow", + }, + ], + }, + { + id: "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub/networkrulesets/default", + name: "default", + type: "Microsoft.EventHub/Namespaces/NetworkRuleSets", + location: "eastus", + publicNetworkAccess: "Enabled", + defaultAction: "Deny", + virtualNetworkRules: [], + ipRules: [ + { + ipMask: "102.18.161.9", + action: "Allow" + } + ], + }, + { + id: "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.EventHub/namespaces/testHub/networkrulesets/default", + name: "default", + type: "Microsoft.EventHub/Namespaces/NetworkRuleSets", + location: "eastus", + publicNetworkAccess: "Enabled", + defaultAction: "Allow", + virtualNetworkRules: [], + ipRules: [ + ], }, ]; -const createCache = (hub) => { +const createCache = (eventHub, networkRuleSet) => { + const id = eventHub && eventHub.length ? eventHub[0].id : null; return { eventHub: { listEventHub: { 'eastus': { - data: hub + data: eventHub } + }, + listNetworkRuleSet: { + 'eastus': { + [id]: { + data: networkRuleSet + } + } + } + } + }; +}; + +const createErrorCache = () => { + return { + eventHub: { + listEventHub: { + eastus: { + err: "error", + }, + }, + }, + }; +}; + +const createNetworkRuleSetErrorCache = (hub) => { + let cache = { + eventHub: { + listEventHub: { + eastus: { + data: hub, + }, + }, + listNetworkRuleSet: { + eastus: {} + } + }, + }; + + if (Array.isArray(hub) && hub.length > 0) { + for (let eventHub of hub) { + if (eventHub.id) { + cache.eventHub.listNetworkRuleSet.eastus[eventHub.id] = { + err: "Unable to query network rule sets", + }; } } } + + return cache; }; -describe('eventHubPublicAccess', function() { - describe('run', function() { - it('should give passing result if no event hub found', function(done) { +describe("eventHubPublicAccess", function () { + describe("run", function () { + it("should give passing result if no event hub 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 Event Hubs namespaces found'); - expect(results[0].region).to.equal('eastus'); - done() + expect(results[0].message).to.include("No Event Hubs namespaces found"); + expect(results[0].region).to.equal("eastus"); + done(); }; const cache = createCache([]); eventHubPublicAccess.run(cache, {}, callback); }); - it('should give failing result if event hub is publicly accessible', function(done) { + it("should give failing result if event hub is publicly accessible", 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('Event Hubs namespace is publicly accessible'); - expect(results[0].region).to.equal('eastus'); - done() + expect(results[0].message).to.include( + "Event Hubs namespace is publicly accessible" + ); + expect(results[0].region).to.equal("eastus"); + done(); }; const cache = createCache([eventHubs[1]]); eventHubPublicAccess.run(cache, {}, callback); }); - it('should give passing result if eventHub is not publicly accessible', function(done) { + it("should give passing result if eventHub is not publicly accessible", 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('Event Hubs namespace is not publicly accessible'); - expect(results[0].region).to.equal('eastus'); - done() + expect(results[0].message).to.include( + "Event Hubs namespace is not publicly accessible" + ); + expect(results[0].region).to.equal("eastus"); + done(); }; const cache = createCache([eventHubs[0]]); eventHubPublicAccess.run(cache, {}, callback); }); - it('should give passing result if eventHub is of basic tier', function(done) { + it("should give passing result if eventHub is of basic tier", 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('Event Hubs namespace tier is basic'); - expect(results[0].region).to.equal('eastus'); - done() + expect(results[0].message).to.include( + "Event Hubs namespace tier is basic" + ); + expect(results[0].region).to.equal("eastus"); + done(); }; const cache = createCache([eventHubs[2]]); eventHubPublicAccess.run(cache, {}, callback); }); - it('should give unknown result if unable to query for event hubs', function(done) { + it("should give unknown result if unable to query for event hubs", 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 Event Hubs namespaces:'); - expect(results[0].region).to.equal('eastus'); - done() + expect(results[0].message).to.include( + "Unable to query for Event Hubs namespaces:" + ); + expect(results[0].region).to.equal("eastus"); + done(); }; - const cache = createCache(null); + const cache = createErrorCache(); eventHubPublicAccess.run(cache, {}, callback); }); - }) -}) \ No newline at end of file + + it("should give passing result when check_selected_networks is enabled and IP rules are configured", 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( + "Event Hubs namespace is not publicly accessible" + ); + expect(results[0].region).to.equal("eastus"); + done(); + }; + + const cache = createCache([eventHubs[3]], networkRuleSets[1]); + eventHubPublicAccess.run( + cache, + { check_selected_networks: true }, + callback + ); + }); + + it("should give failing result when check_selected_networks is enabled and no IP rules are configured", 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( + "Event Hubs namespace is publicly accessible" + ); + expect(results[0].region).to.equal("eastus"); + done(); + }; + + const cache = createCache([eventHubs[5]], networkRuleSets[2]); + eventHubPublicAccess.run( + cache, + { check_selected_networks: true }, + callback + ); + }); + + it("should give unknown result when check_selected_networks is enabled but unable to query network rule sets", 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 Event Hubs network rule set:" + ); + expect(results[0].region).to.equal("eastus"); + done(); + }; + + const cache = createNetworkRuleSetErrorCache([eventHubs[1]]); + eventHubPublicAccess.run( + cache, + { check_selected_networks: true }, + callback + ); + }); + + it("should skip event hub without ID", function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(0); + done(); + }; + + const cache = createCache([eventHubs[4]]); + eventHubPublicAccess.run(cache, {}, callback); + }); + + }); +}); diff --git a/plugins/azure/frontdoor/frontDoorManagedIdentity.js b/plugins/azure/frontdoor/frontDoorManagedIdentity.js index a1a6ea01e8..314eac71ef 100644 --- a/plugins/azure/frontdoor/frontDoorManagedIdentity.js +++ b/plugins/azure/frontdoor/frontDoorManagedIdentity.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Content Delivery', severity: 'Medium', description: 'Ensures that Azure Front Door standard and premium profiles 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 the Front Door standard and premium profile and add managed identity.', link: 'https://learn.microsoft.com/en-us/azure/frontdoor/managed-identity', apis: ['profiles:list'], diff --git a/plugins/azure/keyvaults/keyVaultKeyExpiry.js b/plugins/azure/keyvaults/keyVaultKeyExpiry.js index d6a64304b8..6ba32db018 100644 --- a/plugins/azure/keyvaults/keyVaultKeyExpiry.js +++ b/plugins/azure/keyvaults/keyVaultKeyExpiry.js @@ -2,13 +2,13 @@ var async = require('async'); var helpers = require('../../../helpers/azure'); module.exports = { - title: 'Key Vault Key Expiry', + title: 'Key Vault Key Expiry RBAC', category: 'Key Vaults', domain: 'Application Integration', severity: 'High', - description: 'Proactively check for Key Vault keys expiry date and rotate them before expiry date is reached.', - more_info: 'After expiry date has reached for Key Vault key, it cannot be used for cryptographic operations anymore.', - recommended_action: 'Ensure that Key Vault keys are rotated before they get expired.', + description: 'Ensures that expiration date is set for all keys in RBAC Key Vaults.', + more_info: 'Setting an expiration date on keys helps in key lifecycle management and ensures that keys are rotated regularly.', + recommended_action: 'Modify keys in RBAC Key Vaults to have an expiration date set.', link: 'https://learn.microsoft.com/en-us/azure/key-vault/about-keys-secrets-and-certificates', apis: ['vaults:list', 'vaults:getKeys'], settings: { @@ -30,7 +30,7 @@ module.exports = { }; async.each(locations.vaults, function(location, rcb) { - var vaults = helpers.addSource(cache, source, + var vaults = helpers.addSource(cache, source, ['vaults', 'list', location]); if (!vaults) return rcb(); @@ -45,14 +45,18 @@ module.exports = { return rcb(); } - vaults.data.forEach(function(vault){ + vaults.data.forEach(function(vault) { + if (!vault.enableRbacAuthorization) { + return; + } + var keys = helpers.addSource(cache, source, ['vaults', 'getKeys', location, vault.id]); if (!keys || keys.err || !keys.data) { helpers.addResult(results, 3, 'Unable to query for Key Vault keys: ' + helpers.addError(keys), location, vault.id); } else if (!keys.data.length) { - helpers.addResult(results, 0, 'No Key Vault keys found', location, vault.id); + helpers.addResult(results, 0, 'No Key Vault keys found in RBAC vault', location, vault.id); } else { keys.data.forEach(function(key) { var keyName = key.kid.substring(key.kid.lastIndexOf('/') + 1); @@ -60,23 +64,23 @@ module.exports = { if (!key.attributes || !key.attributes.enabled) { helpers.addResult(results, 0, - 'Key is not enabled', location, keyId); + 'Key in RBAC vault is not enabled', location, keyId); } else if (key.attributes && (key.attributes.expires || key.attributes.exp)) { let keyExpiry = key.attributes.exp ? key.attributes.exp * 1000 : key.attributes.expires; let difference = Math.round((new Date(keyExpiry).getTime() - (new Date).getTime())/(24*60*60*1000)); if (difference > config.key_vault_key_expiry_fail) { helpers.addResult(results, 0, - `Key expires in ${difference} days`, location, keyId); + `Key in RBAC vault expires in ${difference} days`, location, keyId); } else if (difference > 0){ helpers.addResult(results, 2, - `Key expires in ${difference} days`, location, keyId); + `Key in RBAC vault expires in ${difference} days`, location, keyId); } else { helpers.addResult(results, 2, - `Key expired ${Math.abs(difference)} days ago`, location, keyId); + `Key in RBAC vault expired ${Math.abs(difference)} days ago`, location, keyId); } } else { helpers.addResult(results, 0, - 'Key expiration is not enabled', location, keyId); + 'Key expiration is not enabled in RBAC vault', location, keyId); } }); } diff --git a/plugins/azure/keyvaults/keyVaultKeyExpiry.spec.js b/plugins/azure/keyvaults/keyVaultKeyExpiry.spec.js index 6ecdff0c49..7aa4a804b9 100644 --- a/plugins/azure/keyvaults/keyVaultKeyExpiry.spec.js +++ b/plugins/azure/keyvaults/keyVaultKeyExpiry.spec.js @@ -12,48 +12,38 @@ keyExpired.setMonth(keyExpired.getMonth() - 1); const listKeyVaults = [ { - id: '/subscriptions/abcdfget-ebf6-437f-a3b0-28fc0d22111e/resourceGroups/akhtar-rg/providers/Microsoft.KeyVault/vaults/nauman-test', - name: 'nauman-test', + id: '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/test-vault', + name: 'test-vault', + type: 'Microsoft.KeyVault/vaults', + location: 'eastus', + enableRbacAuthorization: true, + properties: { + vaultUri: 'https://test-vault.vault.azure.net/' + } + }, + { + id: '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/test-vault-2', + name: 'test-vault-2', type: 'Microsoft.KeyVault/vaults', location: 'eastus', - tags: { owner: 'kubernetes' }, - sku: { family: 'A', name: 'Standard' }, - tenantId: '2d4f0836-5935-47f5-954c-14e713119ac2', - accessPolicies: [ - { - tenantId: '2d4f0836-5935-47f5-954c-14e713119ac2', - objectId: 'b4062000-c33b-448b-817e-fa0f17bef4b9', - permissions: { - keys: ['Get', 'List'], - secrets: ['Get', 'List'], - certificates: ['Get', 'List'] - } - } - ], - enableSoftDelete: true, - softDeleteRetentionInDays: 7, enableRbacAuthorization: false, - vaultUri: 'https://nauman-test.vault.azure.net/', - provisioningState: 'Succeeded' - } - ]; - - const getKeys = [ + properties: { + vaultUri: 'https://test-vault-2.vault.azure.net/' + } + } +]; + +const getKeys = [ { "attributes": { "created": "2022-04-10T17:57:43+00:00", "enabled": true, "expires": null, "notBefore": null, - "recoveryLevel": "CustomizedRecoverable+Purgeable", "updated": "2022-04-10T17:57:43+00:00" }, - "kid": "https://nauman-test.vault.azure.net/keys/nauman-test", - "managed": null, - "name": "nauman-test", - "tags": { - "hello": "world" - } + "kid": "https://test-vault.vault.azure.net/keys/test-key", + "name": "test-key" }, { "attributes": { @@ -61,15 +51,10 @@ const listKeyVaults = [ "enabled": true, "expires": keyExpiryPass, "notBefore": null, - "recoveryLevel": "CustomizedRecoverable+Purgeable", "updated": "2022-04-10T17:57:43+00:00" }, - "kid": "https://nauman-test.vault.azure.net/keys/nauman-test", - "managed": null, - "name": "nauman-test", - "tags": { - "hello": "world" - } + "kid": "https://test-vault.vault.azure.net/keys/test-key-2", + "name": "test-key-2" }, { "attributes": { @@ -77,15 +62,10 @@ const listKeyVaults = [ "enabled": true, "expires": keyExpiryFail, "notBefore": null, - "recoveryLevel": "CustomizedRecoverable+Purgeable", "updated": "2022-04-10T17:57:43+00:00" }, - "kid": "https://nauman-test.vault.azure.net/keys/nauman-test", - "managed": null, - "name": "nauman-test", - "tags": { - "hello": "world" - } + "kid": "https://test-vault.vault.azure.net/keys/test-key-3", + "name": "test-key-3" }, { "attributes": { @@ -93,15 +73,10 @@ const listKeyVaults = [ "enabled": true, "expires": keyExpired, "notBefore": null, - "recoveryLevel": "CustomizedRecoverable+Purgeable", "updated": "2022-04-10T17:57:43+00:00" }, - "kid": "https://nauman-test.vault.azure.net/keys/nauman-test", - "managed": null, - "name": "nauman-test", - "tags": { - "hello": "world" - } + "kid": "https://test-vault.vault.azure.net/keys/test-key-4", + "name": "test-key-4" } ]; @@ -116,7 +91,7 @@ const createCache = (err, list, keys) => { }, getKeys: { 'eastus': { - '/subscriptions/abcdfget-ebf6-437f-a3b0-28fc0d22111e/resourceGroups/akhtar-rg/providers/Microsoft.KeyVault/vaults/nauman-test': { + '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/test-vault': { err: err, data: keys } @@ -126,9 +101,9 @@ const createCache = (err, list, keys) => { } }; -describe('keyVaultKeyExpiry', function() { +describe('keyVaultKeyExpiryRbac', function() { describe('run', function() { - it('should give passing result if no keys found', function(done) { + it('should give passing result if no key vaults found', function(done) { const callback = (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); @@ -144,7 +119,7 @@ describe('keyVaultKeyExpiry', function() { const callback = (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Key expiration is not enabled'); + expect(results[0].message).to.include('Key expiration is not enabled in RBAC vault'); expect(results[0].region).to.equal('eastus'); done() }; @@ -156,36 +131,36 @@ describe('keyVaultKeyExpiry', function() { const callback = (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Key expires in'); + expect(results[0].message).to.include('Key in RBAC vault expires in'); expect(results[0].region).to.equal('eastus'); done() }; - auth.run(createCache(null, listKeyVaults, [getKeys[1]]), { key_vault_key_expiry_fail: '30' }, callback); + auth.run(createCache(null, [listKeyVaults[0]], [getKeys[1]]), { key_vault_key_expiry_fail: '30' }, callback); }); - it('should give failing results if the key has reached', function(done) { + it('should give failing results if the key has expired', 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('Key expired'); + expect(results[0].message).to.include('Key in RBAC vault expired'); expect(results[0].region).to.equal('eastus'); done() }; - auth.run(createCache(null, listKeyVaults, [getKeys[3]]), { key_vault_key_expiry_fail: '40' }, callback); + auth.run(createCache(null, [listKeyVaults[0]], [getKeys[3]]), { key_vault_key_expiry_fail: '40' }, callback); }); - it('should give failing results if the key expired within failure expiry date', function(done) { + it('should give failing result if the key expires within failure expiry date', 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('Key expires'); + expect(results[0].message).to.include('Key in RBAC vault expires'); expect(results[0].region).to.equal('eastus'); done() }; - auth.run(createCache(null, listKeyVaults, [getKeys[2]]), { key_vault_key_expiry_fail: '40' }, callback); + auth.run(createCache(null, [listKeyVaults[0]], [getKeys[2]]), { key_vault_key_expiry_fail: '40' }, callback); }); }); }); diff --git a/plugins/azure/keyvaults/keyVaultKeyExpiryNonRbac.js b/plugins/azure/keyvaults/keyVaultKeyExpiryNonRbac.js new file mode 100644 index 0000000000..df9665a291 --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultKeyExpiryNonRbac.js @@ -0,0 +1,96 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Key Vault Key Expiry Non RBAC', + category: 'Key Vaults', + domain: 'Application Integration', + severity: 'High', + description: 'Ensures that expiration date is set for all keys in non RBAC Key Vaults.', + more_info: 'Setting an expiration date on keys helps in key lifecycle management and ensures that keys are rotated regularly.', + recommended_action: 'Modify keys in non-RBAC Key Vaults to have an expiration date set.', + link: 'https://learn.microsoft.com/en-us/azure/key-vault/about-keys-secrets-and-certificates', + apis: ['vaults:list', 'vaults:getKeys'], + settings: { + non_rbac_key_vault_key_expiry_fail: { + name: 'Key Vault Key Expiry Fail', + description: 'Return a failing result when key expiration date is within this number of days in the future', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '30' + } + }, + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + var config = { + key_vault_key_expiry_fail: parseInt(settings.non_rbac_key_vault_key_expiry_fail || this.settings.non_rbac_key_vault_key_expiry_fail.default) + }; + + async.each(locations.vaults, function(location, rcb) { + var vaults = helpers.addSource(cache, source, + ['vaults', 'list', location]); + + if (!vaults) return rcb(); + + if (vaults.err || !vaults.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vaults: ' + helpers.addError(vaults), location); + return rcb(); + } + + if (!vaults.data.length) { + helpers.addResult(results, 0, 'No Key Vaults found', location); + return rcb(); + } + + vaults.data.forEach(function(vault) { + if (vault.enableRbacAuthorization) { + return; + } + + var keys = helpers.addSource(cache, source, + ['vaults', 'getKeys', location, vault.id]); + + if (!keys || keys.err || !keys.data) { + helpers.addResult(results, 3, + 'Unable to query for Key Vault keys: ' + helpers.addError(keys), location, vault.id); + } else if (!keys.data.length) { + helpers.addResult(results, 0, + 'No Key Vault keys found in non RBAC vault', location, vault.id); + } else { + keys.data.forEach(function(key) { + var keyName = key.kid.substring(key.kid.lastIndexOf('/') + 1); + var keyId = `${vault.id}/keys/${keyName}`; + + if (!key.attributes || !key.attributes.enabled) { + helpers.addResult(results, 0, + 'Key in non RBAC vault is not enabled', location, keyId); + } else if (key.attributes && (key.attributes.expires || key.attributes.exp)) { + let keyExpiry = key.attributes.exp ? key.attributes.exp * 1000 : key.attributes.expires; + let difference = Math.round((new Date(keyExpiry).getTime() - (new Date).getTime())/(24*60*60*1000)); + if (difference > config.key_vault_key_expiry_fail) { + helpers.addResult(results, 0, + `Key in non RBAC vault expires in ${difference} days`, location, keyId); + } else if (difference > 0){ + helpers.addResult(results, 2, + `Key in non RBAC vault expires in ${difference} days`, location, keyId); + } else { + helpers.addResult(results, 2, + `Key in non RBAC vault expired ${Math.abs(difference)} days ago`, location, keyId); + } + } else { + helpers.addResult(results, 0, + 'Key expiration is not enabled in non RBAC vault', location, keyId); + } + }); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/keyvaults/keyVaultKeyExpiryNonRbac.spec.js b/plugins/azure/keyvaults/keyVaultKeyExpiryNonRbac.spec.js new file mode 100644 index 0000000000..43ed0ab134 --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultKeyExpiryNonRbac.spec.js @@ -0,0 +1,175 @@ +var expect = require('chai').expect; +var auth = require('./keyVaultKeyExpiryNonRbac'); + +var keyExpiryPass = new Date(); +keyExpiryPass.setMonth(keyExpiryPass.getMonth() + 2); + +var keyExpiryFail = new Date(); +keyExpiryFail.setDate(keyExpiryFail.getDate() + 25); + +var keyExpired = new Date(); +keyExpired.setMonth(keyExpired.getMonth() - 1); + +const listKeyVaults = [ + { + id: '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/test-vault', + name: 'test-vault', + type: 'Microsoft.KeyVault/vaults', + location: 'eastus', + enableRbacAuthorization: false, + properties: { + vaultUri: 'https://test-vault.vault.azure.net/' + } + }, + { + id: '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/test-vault-2', + name: 'test-vault-2', + type: 'Microsoft.KeyVault/vaults', + location: 'eastus', + enableRbacAuthorization: true, + properties: { + vaultUri: 'https://test-vault-2.vault.azure.net/' + } + } +]; + +const getKeys = [ + { + "attributes": { + "created": "2022-04-10T17:57:43+00:00", + "enabled": true, + "expires": null, + "notBefore": null, + "updated": "2022-04-10T17:57:43+00:00" + }, + "kid": "https://test-vault.vault.azure.net/keys/test-key", + "name": "test-key" + }, + { + "attributes": { + "created": "2022-04-10T17:57:43+00:00", + "enabled": true, + "expires": keyExpiryPass, + "notBefore": null, + "updated": "2022-04-10T17:57:43+00:00" + }, + "kid": "https://test-vault.vault.azure.net/keys/test-key-2", + "name": "test-key-2" + }, + { + "attributes": { + "created": "2022-04-10T17:57:43+00:00", + "enabled": true, + "expires": keyExpiryFail, + "notBefore": null, + "updated": "2022-04-10T17:57:43+00:00" + }, + "kid": "https://test-vault.vault.azure.net/keys/test-key-3", + "name": "test-key-3" + }, + { + "attributes": { + "created": "2022-04-10T17:57:43+00:00", + "enabled": true, + "expires": keyExpired, + "notBefore": null, + "updated": "2022-04-10T17:57:43+00:00" + }, + "kid": "https://test-vault.vault.azure.net/keys/test-key-4", + "name": "test-key-4" + } +]; + +const createCache = (err, list, keys) => { + return { + vaults: { + list: { + 'eastus': { + err: err, + data: list + } + }, + getKeys: { + 'eastus': { + '/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.KeyVault/vaults/test-vault': { + err: err, + data: keys + } + } + } + } + } +}; + +describe('keyVaultKeyExpiryNonRbac', function() { + describe('run', function() { + it('should give passing result if no key vaults 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 Key Vaults found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [], {}), {}, callback); + }); + + it('should give passing result if no non-RBAC key vaults found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(0); // No results since we skip RBAC vaults + done() + }; + + auth.run(createCache(null, [listKeyVaults[1]], []), {}, callback); + }); + + it('should give passing result if expiration is not set on keys in non-RBAC vault', 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('Key expiration is not enabled in non RBAC vault'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], [getKeys[0]]), {}, callback); + }); + + it('should give passing result if expiry date is not yet reached in non-RBAC vault', 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('Key in non RBAC vault expires in'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], [getKeys[1]]), { key_vault_key_expiry_fail: '30' }, callback); + }); + + it('should give failing result if the key has expired in non-RBAC vault', 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('Key in non RBAC vault expired'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], [getKeys[3]]), { key_vault_key_expiry_fail: '40' }, callback); + }); + + it('should give failing result if the key expires within failure expiry date in non-RBAC vault', 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('Key in non RBAC vault expires'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], [getKeys[2]]), { key_vault_key_expiry_fail: '40' }, callback); + }); + }); +}); diff --git a/plugins/azure/keyvaults/keyVaultPublicAccess.js b/plugins/azure/keyvaults/keyVaultPublicAccess.js new file mode 100644 index 0000000000..1e0459769b --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultPublicAccess.js @@ -0,0 +1,118 @@ +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Key Vault Public Access', + category: 'Key Vault', + domain: 'Security', + severity: 'High', + description: 'Ensures that Azure Key Vaults do not allow unrestricted public access', + more_info: 'Azure Key Vaults should be configured to restrict public access to protect sensitive data. This can be achieved by either disabling public network access or implementing strict network rules.', + recommended_action: 'Modify Key Vault network settings to disable public access or appropriate configure network rules.', + link: 'https://learn.microsoft.com/en-us/azure/key-vault/general/network-security', + apis: ['vaults:list'], + settings: { + keyvault_allowed_ips: { + name: 'Key Vault Allowed IPs', + description: 'Comma-separated list of IP addresses that are explicitly allowed to access Key Vaults', + regex: '^(?:\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(?:/\\d{1,2})?(?:,\\s*)?)+$', + default: '' + } + }, + realtime_triggers: ['microsoft.keyvault:vaults:write', 'microsoft.keyvault:vaults:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + var config = { + keyvault_allowed_ips: settings.keyvault_allowed_ips || this.settings.keyvault_allowed_ips.default + }; + + var allowedIps = []; + if (config.keyvault_allowed_ips && config.keyvault_allowed_ips.length) { + allowedIps = config.keyvault_allowed_ips.split(',').map(ip => ip.trim()); + } + var checkAllowedIps = allowedIps.length > 0; + + async.each(locations.vaults, function(location, rcb) { + var vaults = helpers.addSource(cache, source, + ['vaults', 'list', location]); + + if (!vaults) return rcb(); + + if (vaults.err || !vaults.data) { + helpers.addResult(results, 3, + 'Unable to query for Key Vaults: ' + helpers.addError(vaults), location); + return rcb(); + } + + if (!vaults.data.length) { + helpers.addResult(results, 0, 'No Key Vaults found', location); + return rcb(); + } + + vaults.data.forEach(function(vault) { + if (!vault.id) return; + + if (vault && + vault.publicNetworkAccess && + vault.publicNetworkAccess.toLowerCase() === 'disabled') { + helpers.addResult(results, 0, + 'Key Vault is protected from outside traffic', + location, vault.id); + return; + } + + + if (vault && vault.networkAcls) { + var networkAcls = vault.networkAcls; + var defaultAction = networkAcls.defaultAction ? networkAcls.defaultAction.toLowerCase() : null; + + if (!defaultAction || defaultAction === 'allow') { + helpers.addResult(results, 2, + 'Key Vault is open to outside traffic', + location, vault.id); + return; + } + + if (defaultAction === 'deny') { + var ipRules = networkAcls.ipRules || []; + var hasPublicAccess = false; + var publicAccessFound = []; + + for (var rule of ipRules) { + if (checkAllowedIps) { + if (!allowedIps.includes(rule.value)) { + hasPublicAccess = true; + publicAccessFound.push(rule.value); + } + } else if (rule.value === '0.0.0.0/0' || rule.value === '0.0.0.0') { + hasPublicAccess = true; + publicAccessFound.push(rule.value); + } + } + + if (hasPublicAccess) { + helpers.addResult(results, 2, + `Key Vault is open to outside traffic through IP rules: ${publicAccessFound.join(', ')}`, + location, vault.id); + } else { + var message = 'Key Vault is protected from outside traffic'; + helpers.addResult(results, 0, message, location, vault.id); + } + } + } else { + helpers.addResult(results, 2, + 'Key Vault is open to outside traffic', + location, vault.id); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/keyvaults/keyVaultPublicAccess.spec.js b/plugins/azure/keyvaults/keyVaultPublicAccess.spec.js new file mode 100644 index 0000000000..e79bdbee46 --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultPublicAccess.spec.js @@ -0,0 +1,192 @@ +var expect = require('chai').expect; +var keyVaultPublicAccess = require('./keyVaultPublicAccess'); + +const vaults = [ + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.KeyVault/vaults/test1", + "name": "test1", + "type": "Microsoft.KeyVault/vaults", + "publicNetworkAccess": "Disabled" + + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.KeyVault/vaults/test2", + "name": "test2", + "type": "Microsoft.KeyVault/vaults", + "publicNetworkAccess": "Enabled", + "networkAcls": { + "defaultAction": "Deny", + "ipRules": [ + { + "value": "10.0.0.0/16" + } + ] + } + + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.KeyVault/vaults/test3", + "name": "test3", + "type": "Microsoft.KeyVault/vaults", + "publicNetworkAccess": "Enabled", + "networkAcls": { + "defaultAction": "Allow", + "ipRules": [] + } + + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.KeyVault/vaults/test4", + "name": "test4", + "type": "Microsoft.KeyVault/vaults", + "publicNetworkAccess": "Enabled", + "networkAcls": { + "defaultAction": "Deny", + "ipRules": [ + { + "value": "0.0.0.0/0" + } + ] + } + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.KeyVault/vaults/test6", + "name": "test6", + "type": "Microsoft.KeyVault/vaults", + "publicNetworkAccess": "Enabled" + + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.KeyVault/vaults/test7", + "name": "test7", + "type": "Microsoft.KeyVault/vaults", + "publicNetworkAccess": "Enabled", + "networkAcls": { + "defaultAction": "Deny", + "ipRules": [ + { + "value": "192.168.1.1" + } + ] + + } + } +]; + +const createCache = (vaults) => { + return { + vaults: { + list: { + 'eastus': { + data: vaults + } + } + } + }; +}; + +const createErrorCache = () => { + return { + vaults: { + list: { + 'eastus': { + err: { + message: 'error loading vaults' + } + } + } + } + }; +}; + +describe('keyVaultPublicAccess', function () { + describe('run', function () { + it('should give passing result if no key vaults found', function (done) { + const cache = createCache([]); + keyVaultPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Key Vaults found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for key vaults', function (done) { + const cache = createErrorCache(); + keyVaultPublicAccess.run(cache, {}, (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 Key Vaults'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if public network access is disabled', function (done) { + const cache = createCache([vaults[0]]); + keyVaultPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Key Vault is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if default action is deny and no public IPs allowed', function (done) { + const cache = createCache([vaults[1]]); + keyVaultPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Key Vault is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if default action is allow', function (done) { + const cache = createCache([vaults[2]]); + keyVaultPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Key Vault is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if IPv4 public access is allowed', function (done) { + const cache = createCache([vaults[3]]); + keyVaultPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Key Vault is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if no network ACLs configured', function (done) { + const cache = createCache([vaults[4]]); + keyVaultPublicAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Key Vault is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if IP is in allowed list', function (done) { + const cache = createCache([vaults[5]]); + keyVaultPublicAccess.run(cache, { keyvault_allowed_ips: '192.168.1.1' }, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Key Vault is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/keyvaults/keyVaultSecretExpiry.js b/plugins/azure/keyvaults/keyVaultSecretExpiry.js index 2fe0f307a0..2dd31788d5 100644 --- a/plugins/azure/keyvaults/keyVaultSecretExpiry.js +++ b/plugins/azure/keyvaults/keyVaultSecretExpiry.js @@ -2,11 +2,11 @@ var async = require('async'); var helpers = require('../../../helpers/azure'); module.exports = { - title: 'Key Vault Secret Expiry', + title: 'Key Vault Secret Expiry RBAC', category: 'Key Vaults', domain: 'Application Integration', severity: 'High', - description: 'Proactively check for Key Vault secrets expiry date and rotate them before expiry date is reached.', + description: 'Proactively check for RBAC Key Vault secrets expiry date and rotate them before expiry date is reached.', more_info: 'After the expiry date has reached for Key Vault secret, it cannot be used for storing sensitive and confidential data such as passwords and database connection strings anymore.', recommended_action: 'Ensure that Key Vault secrets are rotated before they get expired.', link: 'https://learn.microsoft.com/en-us/azure/secret-vault/about-secrets-secrets-and-certificates', @@ -46,37 +46,42 @@ module.exports = { } vaults.data.forEach(function(vault) { + // Check if vault is RBAC-enabled + if (!vault.enableRbacAuthorization) { + return; + } + var secrets = helpers.addSource(cache, source, ['vaults', 'getSecrets', location, vault.id]); if (!secrets || secrets.err || !secrets.data) { helpers.addResult(results, 3, 'Unable to query for Key Vault secrets: ' + helpers.addError(secrets), location, vault.id); } else if (!secrets.data.length) { - helpers.addResult(results, 0, 'No Key Vault secrets found', location, vault.id); + helpers.addResult(results, 0, 'No Key Vault secrets found in RBAC vault', location, vault.id); } else { secrets.data.forEach(function(secret) { var secretName = secret.id.substring(secret.id.lastIndexOf('/') + 1); var secretId = `${vault.id}/secrets/${secretName}`; if (!secret.attributes || !secret.attributes.enabled) { - helpers.addResult(results, 0, 'Secret is not enabled', location, secretId); + helpers.addResult(results, 0, 'Secret in RBAC vault is not enabled', location, secretId); } else if (secret.attributes && (secret.attributes.exp || secret.attributes.expiry)) { let attributes = secret.attributes; let secretExpiry = attributes.exp ? attributes.exp * 1000 : attributes.expiry; let difference = Math.round((new Date(secretExpiry).getTime() - (new Date).getTime())/(24*60*60*1000)); if (difference > config.key_vault_secret_expiry_fail) { helpers.addResult(results, 0, - `Secret expires in ${difference} days`, location, secretId); + `Secret in RBAC vault expires in ${difference} days`, location, secretId); } else if (difference > 0){ helpers.addResult(results, 2, - `Secret expires in ${difference} days`, location, secretId); + `Secret in RBAC vault expires in ${difference} days`, location, secretId); } else { helpers.addResult(results, 2, - `Secret expired ${Math.abs(difference)} days ago`, location, secretId); + `Secret in RBAC vault expired ${Math.abs(difference)} days ago`, location, secretId); } } else { helpers.addResult(results, 0, - 'Secret expiration is not enabled', location, secretId); + 'Secret expiration is not enabled in RBAC vault', location, secretId); } }); } diff --git a/plugins/azure/keyvaults/keyVaultSecretExpiry.spec.js b/plugins/azure/keyvaults/keyVaultSecretExpiry.spec.js index d08c57a3c9..4bf693b9e5 100644 --- a/plugins/azure/keyvaults/keyVaultSecretExpiry.spec.js +++ b/plugins/azure/keyvaults/keyVaultSecretExpiry.spec.js @@ -20,7 +20,9 @@ const listKeyVaults = [ "sku": { "family": "A", "name": "Standard" - } + }, + "enableRbacAuthorization": true, + }, { "id": "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault", @@ -31,7 +33,8 @@ const listKeyVaults = [ "sku": { "family": "A", "name": "Standard" - } + }, + "enableRbacAuthorization": false, } ]; @@ -152,11 +155,11 @@ describe('keyVaultSecretExpiry', function() { auth.run(createCache(null, [], {}), {}, callback); }); - it('should give passing result if secret expiration is not enabled', function(done) { + it('should give passing result if secret expiration is not enabled in RBAC vault', 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('Secret expiration is not enabled'); + expect(results[0].message).to.include('Secret expiration is not enabled in RBAC vault'); expect(results[0].region).to.equal('eastus'); done() }; @@ -164,11 +167,11 @@ describe('keyVaultSecretExpiry', function() { auth.run(createCache(null, [listKeyVaults[0]], getSecrets[0]), {}, callback); }); - it('should give passing result if secret expiry is not yet reached', function(done) { + it('should give passing result if secret expiry is not yet reached in RBAC vault', 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('Secret expires'); + expect(results[0].message).to.include('Secret in RBAC vault expires'); expect(results[0].region).to.equal('eastus'); done() }; @@ -180,7 +183,7 @@ describe('keyVaultSecretExpiry', function() { const callback = (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Secret expired'); + expect(results[0].message).to.include('Secret in RBAC vault expired'); expect(results[0].region).to.equal('eastus'); done() }; @@ -192,7 +195,7 @@ describe('keyVaultSecretExpiry', function() { const callback = (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Secret expires'); + expect(results[0].message).to.include('Secret in RBAC vault expires'); expect(results[0].region).to.equal('eastus'); done() }; @@ -204,7 +207,7 @@ describe('keyVaultSecretExpiry', function() { const callback = (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Secret is not enabled'); + expect(results[0].message).to.include('Secret in RBAC vault is not enabled'); expect(results[0].region).to.equal('eastus'); done() }; diff --git a/plugins/azure/keyvaults/keyVaultSecretExpiryNonRbac.js b/plugins/azure/keyvaults/keyVaultSecretExpiryNonRbac.js new file mode 100644 index 0000000000..e2f8a15fd1 --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultSecretExpiryNonRbac.js @@ -0,0 +1,95 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Key Vault Secret Expiry Non RBAC', + category: 'Key Vaults', + domain: 'Application Integration', + severity: 'High', + description: 'Proactively check for non RBAC Key Vault secrets expiry date and rotate them before expiry date is reached.', + more_info: 'After the expiry date has reached for Key Vault secret, it cannot be used for storing sensitive and confidential data such as passwords and database connection strings anymore.', + recommended_action: 'Ensure that Key Vault secrets are rotated before they get expired.', + link: 'https://learn.microsoft.com/en-us/azure/secret-vault/about-secrets-secrets-and-certificates', + apis: ['vaults:list', 'vaults:getSecrets'], + settings: { + non_rbac_key_vault_secret_expiry_fail: { + name: 'Key Vault Secret Expiry Fail', + description: 'Return a failing result when secret expiration date is within this number of days in the future', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: '30' + } + }, + realtime_triggers: ['microsoftkeyvault:vaults:write', 'microsoftkeyvault:vaults:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + var config = { + key_vault_secret_expiry_fail: parseInt(settings.non_rbac_key_vault_secret_expiry_fail || this.settings.non_rbac_key_vault_secret_expiry_fail.default) + }; + + async.each(locations.vaults, function(location, rcb) { + var vaults = helpers.addSource(cache, source, + ['vaults', 'list', location]); + + if (!vaults) return rcb(); + + if (vaults.err || !vaults.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vaults: ' + helpers.addError(vaults), location); + return rcb(); + } + + if (!vaults.data.length) { + helpers.addResult(results, 0, 'No Key Vaults found', location); + return rcb(); + } + + vaults.data.forEach(function(vault) { + // Check if vault is non-RBAC + if (vault.enableRbacAuthorization) { + return; + } + + var secrets = helpers.addSource(cache, source, + ['vaults', 'getSecrets', location, vault.id]); + + if (!secrets || secrets.err || !secrets.data) { + helpers.addResult(results, 3, 'Unable to query for Key Vault secrets: ' + helpers.addError(secrets), location, vault.id); + } else if (!secrets.data.length) { + helpers.addResult(results, 0, 'No Key Vault secrets found in non RBAC vault', location, vault.id); + } else { + secrets.data.forEach(function(secret) { + var secretName = secret.id.substring(secret.id.lastIndexOf('/') + 1); + var secretId = `${vault.id}/secrets/${secretName}`; + + if (!secret.attributes || !secret.attributes.enabled) { + helpers.addResult(results, 0, 'Secret is not enabled in non RBAC vault', location, secretId); + } else if (secret.attributes && (secret.attributes.exp || secret.attributes.expiry)) { + let attributes = secret.attributes; + let secretExpiry = attributes.exp ? attributes.exp * 1000 : attributes.expiry; + let difference = Math.round((new Date(secretExpiry).getTime() - (new Date).getTime())/(24*60*60*1000)); + if (difference > config.key_vault_secret_expiry_fail) { + helpers.addResult(results, 0, + `Secret in non RBAC vault expires in ${difference} days`, location, secretId); + } else if (difference > 0){ + helpers.addResult(results, 2, + `Secret in non RBAC vault expires in ${difference} days`, location, secretId); + } else { + helpers.addResult(results, 2, + `Secret in non RBAC vault expired ${Math.abs(difference)} days ago`, location, secretId); + } + } else { + helpers.addResult(results, 0, + 'Secret expiration is not enabled in non RBAC vault', location, secretId); + } + }); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/keyvaults/keyVaultSecretExpiryNonRbac.spec.js b/plugins/azure/keyvaults/keyVaultSecretExpiryNonRbac.spec.js new file mode 100644 index 0000000000..2a64720d2e --- /dev/null +++ b/plugins/azure/keyvaults/keyVaultSecretExpiryNonRbac.spec.js @@ -0,0 +1,221 @@ +var expect = require('chai').expect; +var auth = require('./keyVaultSecretExpiryNonRbac'); + +var secretExpiryPass = new Date(); +secretExpiryPass.setMonth(secretExpiryPass.getMonth() + 2); + +var secretExpiryFail = new Date(); +secretExpiryFail.setDate(secretExpiryFail.getDate() + 25); // Set to 35 days in the future + +var secretExpired = new Date(); +secretExpired.setMonth(secretExpired.getMonth() - 1); + +const listKeyVaults = [ + { + "id": "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault", + "name": "testvault", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + }, + "properties": { + "enableRbacAuthorization": false // Non-RBAC vault + } + }, + { + "id": "/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault", + "name": "testvault", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + }, + "properties": { + "enableRbacAuthorization": true // RBAC vault + } + } +]; + +const getSecrets = [ + { + '/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault': { + data: [ + { + "id": "https://testvault.vault.azure.net/secrets/mysecret", + "attributes": { + "enabled": true, + "expiry": null, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": {} + } + ] + } + },{ + '/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault': { + data: [ + { + "id": "https://testvault.vault.azure.net/secrets/mysecret", + "attributes": { + "enabled": true, + "expiry": secretExpiryPass, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": {} + } + ] + } + }, + { + '/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault': { + data: [ + { + "id": "https://testvault.vault.azure.net/secrets/mysecret", + "attributes": { + "enabled": true, + "expiry": secretExpiryFail, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": {} + } + ] + } + }, + { + '/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault': { + data: [ + { + "id": "https://testvault.vault.azure.net/secrets/mysecret", + "attributes": { + "enabled": true, + "expiry": secretExpired, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": {} + } + ] + } + }, + { + '/subscriptions/abcdef123-ebf6-437f-a3b0-28fc0d22117e/resourceGroups/Default-ActivityLogAlerts/providers/Microsoft.KeyVault/vaults/testvault': { + data: [ + { + "id": "https://testvault.vault.azure.net/secrets/mysecret", + "attributes": { + "enabled": false, + "expiry": secretExpired, + "created": 1572289869, + "updated": 1572290380, + "recoveryLevel": "Recoverable+Purgeable" + }, + "tags": {} + } + ] + } + } +]; + +const createCache = (err, list, get) => { + return { + vaults: { + list: { + 'eastus': { + err: err, + data: list + } + }, + getSecrets: { + 'eastus': get + } + } + } +}; + +describe('keyVaultSecretExpiryNonRbac', function() { + describe('run', function() { + it('should give passing result if no secrets 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 Key Vaults found'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [], {}), {}, callback); + }); + + it('should give passing result if secret expiration is not enabled in non-RBAC vault', 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('Secret expiration is not enabled in non RBAC vault'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], getSecrets[0]), {}, callback); + }); + + it('should give passing result if secret expiry is not yet reached in non-RBAC vault', 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('Secret in non RBAC vault expires'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], getSecrets[1]), {}, callback); + }); + + it('should give failing result if secret has expired', 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('Secret in non RBAC vault expired'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], getSecrets[3]), {}, callback); + }); + + it('should give failing result if secret expires within failure expiry date', 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('Secret in non RBAC vault expires'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], getSecrets[2]), { key_vault_secret_expiry_fail: '40' }, callback); + }); + + it('should give passing result if key is disabled', 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('Secret is not enabled in non RBAC vault'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listKeyVaults[0]], getSecrets[4]), {}, callback); + }); + }) +}); diff --git a/plugins/azure/keyvaults/restrictDefaultNetworkAccess.js b/plugins/azure/keyvaults/restrictDefaultNetworkAccess.js index 43ffc27ae8..e9362dbcd8 100644 --- a/plugins/azure/keyvaults/restrictDefaultNetworkAccess.js +++ b/plugins/azure/keyvaults/restrictDefaultNetworkAccess.js @@ -35,18 +35,14 @@ module.exports = { } vaults.data.forEach((vault) => { - if (vault.networkAcls){ - if (vault.networkAcls && ((!vault.networkAcls.defaultAction) || - (vault.networkAcls.defaultAction && vault.networkAcls.defaultAction === 'Allow'))) { - helpers.addResult(results, 2, - 'Key Vault allows access to all networks', location, vault.id); - } else { - helpers.addResult(results, 0, - 'Key Vault does not allow access to all networks', location, vault.id); - } - } else { + if (vault.networkAcls && + vault.networkAcls.defaultAction && + vault.networkAcls.defaultAction === 'Deny') { helpers.addResult(results, 0, - 'Network Acl is not configured for Key Vault', location, vault.id); + 'Key Vault does not allow access to all networks', location, vault.id); + } else { + helpers.addResult(results, 2, + 'Key Vault allows access to all networks', location, vault.id); } }); diff --git a/plugins/azure/keyvaults/restrictDefaultNetworkAccess.spec.js b/plugins/azure/keyvaults/restrictDefaultNetworkAccess.spec.js index b7c0aa7a4c..4b3d624b78 100644 --- a/plugins/azure/keyvaults/restrictDefaultNetworkAccess.spec.js +++ b/plugins/azure/keyvaults/restrictDefaultNetworkAccess.spec.js @@ -3,89 +3,89 @@ var auth = require('./restrictDefaultNetworkAccess'); const listVaults = [ { - "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.KeyVault/vaults/xZbb", + "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.KeyVault/vaults/xZbb", "name": "xZbb", "type": "Microsoft.KeyVault/vaults", "location": "eastus", "tags": {}, "sku": { - "family": "A", - "name": "Standard" + "family": "A", + "name": "Standard" }, "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", "networkAcls": { - "bypass": "None", - "defaultAction": "Deny", - "ipRules": [], - "virtualNetworkRules": [ - { - "id": "/subscriptions/1234/resourcegroups/akhtar-rg/providers/microsoft.network/virtualnetworks/akhtar-rg-vnet/subnets/default", - "ignoreMissingVnetServiceEndpoint": false - } - ] + "bypass": "None", + "defaultAction": "Deny", + "ipRules": [], + "virtualNetworkRules": [ + { + "id": "/subscriptions/1234/resourcegroups/akhtar-rg/providers/microsoft.network/virtualnetworks/akhtar-rg-vnet/subnets/default", + "ignoreMissingVnetServiceEndpoint": false + } + ] }, "privateEndpointConnections": [ - { - "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.KeyVault/vaults/xZbb/privateEndpointConnections/sadeed", - "properties": { - "provisioningState": "Succeeded", - "privateEndpoint": { - "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.Network/privateEndpoints/sadeed" - }, - "privateLinkServiceConnectionState": { - "status": "Approved", - "actionsRequired": "None" - } + { + "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.KeyVault/vaults/xZbb/privateEndpointConnections/sadeed", + "properties": { + "provisioningState": "Succeeded", + "privateEndpoint": { + "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.Network/privateEndpoints/sadeed" + }, + "privateLinkServiceConnectionState": { + "status": "Approved", + "actionsRequired": "None" + } + } } - } ], "accessPolicies": [ - { - "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", - "objectId": "d198cb4d-de06-40ff-8fc4-4f643fbeabc5", - "permissions": { - "keys": [ - "Get", - "List", - "Update", - "Create", - "Import", - "Delete", - "Recover", - "Backup", - "Restore", - "GetRotationPolicy", - "SetRotationPolicy", - "Rotate" - ], - "secrets": [ - "Get", - "List", - "Set", - "Delete", - "Recover", - "Backup", - "Restore" - ], - "certificates": [ - "Get", - "List", - "Update", - "Create", - "Import", - "Delete", - "Recover", - "Backup", - "Restore", - "ManageContacts", - "ManageIssuers", - "GetIssuers", - "ListIssuers", - "SetIssuers", - "DeleteIssuers" - ] + { + "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", + "objectId": "d198cb4d-de06-40ff-8fc4-4f643fbeabc5", + "permissions": { + "keys": [ + "Get", + "List", + "Update", + "Create", + "Import", + "Delete", + "Recover", + "Backup", + "Restore", + "GetRotationPolicy", + "SetRotationPolicy", + "Rotate" + ], + "secrets": [ + "Get", + "List", + "Set", + "Delete", + "Recover", + "Backup", + "Restore" + ], + "certificates": [ + "Get", + "List", + "Update", + "Create", + "Import", + "Delete", + "Recover", + "Backup", + "Restore", + "ManageContacts", + "ManageIssuers", + "GetIssuers", + "ListIssuers", + "SetIssuers", + "DeleteIssuers" + ] + } } - } ], "enabledForDeployment": false, "enabledForDiskEncryption": false, @@ -97,89 +97,89 @@ const listVaults = [ "provisioningState": "Succeeded" }, { - "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.KeyVault/vaults/xZbb", + "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.KeyVault/vaults/xZbb", "name": "xZbb", "type": "Microsoft.KeyVault/vaults", "location": "eastus", "tags": {}, "sku": { - "family": "A", - "name": "Standard" + "family": "A", + "name": "Standard" }, "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", "networkAcls": { - "bypass": "None", - "defaultAction": "Allow", - "ipRules": [], - "virtualNetworkRules": [ - { - "id": "/subscriptions/1234/resourcegroups/akhtar-rg/providers/microsoft.network/virtualnetworks/akhtar-rg-vnet/subnets/default", - "ignoreMissingVnetServiceEndpoint": false - } - ] + "bypass": "None", + "defaultAction": "Allow", + "ipRules": [], + "virtualNetworkRules": [ + { + "id": "/subscriptions/1234/resourcegroups/akhtar-rg/providers/microsoft.network/virtualnetworks/akhtar-rg-vnet/subnets/default", + "ignoreMissingVnetServiceEndpoint": false + } + ] }, "privateEndpointConnections": [ - { - "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.KeyVault/vaults/xZbb/privateEndpointConnections/sadeed", - "properties": { - "provisioningState": "Succeeded", - "privateEndpoint": { - "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.Network/privateEndpoints/sadeed" - }, - "privateLinkServiceConnectionState": { - "status": "Approved", - "actionsRequired": "None" - } + { + "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.KeyVault/vaults/xZbb/privateEndpointConnections/sadeed", + "properties": { + "provisioningState": "Succeeded", + "privateEndpoint": { + "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.Network/privateEndpoints/sadeed" + }, + "privateLinkServiceConnectionState": { + "status": "Approved", + "actionsRequired": "None" + } + } } - } ], "accessPolicies": [ - { - "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", - "objectId": "d198cb4d-de06-40ff-8fc4-4f643fbeabc5", - "permissions": { - "keys": [ - "Get", - "List", - "Update", - "Create", - "Import", - "Delete", - "Recover", - "Backup", - "Restore", - "GetRotationPolicy", - "SetRotationPolicy", - "Rotate" - ], - "secrets": [ - "Get", - "List", - "Set", - "Delete", - "Recover", - "Backup", - "Restore" - ], - "certificates": [ - "Get", - "List", - "Update", - "Create", - "Import", - "Delete", - "Recover", - "Backup", - "Restore", - "ManageContacts", - "ManageIssuers", - "GetIssuers", - "ListIssuers", - "SetIssuers", - "DeleteIssuers" - ] + { + "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", + "objectId": "d198cb4d-de06-40ff-8fc4-4f643fbeabc5", + "permissions": { + "keys": [ + "Get", + "List", + "Update", + "Create", + "Import", + "Delete", + "Recover", + "Backup", + "Restore", + "GetRotationPolicy", + "SetRotationPolicy", + "Rotate" + ], + "secrets": [ + "Get", + "List", + "Set", + "Delete", + "Recover", + "Backup", + "Restore" + ], + "certificates": [ + "Get", + "List", + "Update", + "Create", + "Import", + "Delete", + "Recover", + "Backup", + "Restore", + "ManageContacts", + "ManageIssuers", + "GetIssuers", + "ListIssuers", + "SetIssuers", + "DeleteIssuers" + ] + } } - } ], "enabledForDeployment": false, "enabledForDiskEncryption": false, @@ -190,6 +190,55 @@ const listVaults = [ "vaultUri": "https://xzbb.vault.azure.net/", "provisioningState": "Succeeded" }, + { + "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.KeyVault/vaults/noNetworkAcls", + "name": "noNetworkAcls", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + }, + "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", + "privateEndpointConnections": [], + "accessPolicies": [], + "enabledForDeployment": false, + "enabledForDiskEncryption": false, + "enabledForTemplateDeployment": false, + "enableSoftDelete": true, + "softDeleteRetentionInDays": 90, + "enableRbacAuthorization": false, + "vaultUri": "https://nonetworkacls.vault.azure.net/", + "provisioningState": "Succeeded" + }, + { + "id": "/subscriptions/1234/resourceGroups/testrg/providers/Microsoft.KeyVault/vaults/emptyDefaultAction", + "name": "emptyDefaultAction", + "type": "Microsoft.KeyVault/vaults", + "location": "eastus", + "tags": {}, + "sku": { + "family": "A", + "name": "Standard" + }, + "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2", + "networkAcls": { + "bypass": "None", + "ipRules": [], + "virtualNetworkRules": [] + }, + "privateEndpointConnections": [], + "accessPolicies": [], + "enabledForDeployment": false, + "enabledForDiskEncryption": false, + "enabledForTemplateDeployment": false, + "enableSoftDelete": true, + "softDeleteRetentionInDays": 90, + "enableRbacAuthorization": false, + "vaultUri": "https://emptydefaultaction.vault.azure.net/", + "provisioningState": "Succeeded" + } ]; const createCache = (err, list, get) => { @@ -256,6 +305,30 @@ describe('restrictDefaultNetworkAccess', function() { }; auth.run(createCache(null, [listVaults[1]]), {}, callback); - }) + }); + + it('should give failing result if Key Vault has no networkAcls configured', 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('Key Vault allows access to all networks'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[2]]), {}, callback); + }); + + it('should give failing result if Key Vault has networkAcls but no defaultAction', 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('Key Vault allows access to all networks'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + auth.run(createCache(null, [listVaults[3]]), {}, callback); + }); }) }); diff --git a/plugins/azure/kubernetesservice/aksLatestVersion.js b/plugins/azure/kubernetesservice/aksLatestVersion.js index 406d0a44e3..e5900accda 100644 --- a/plugins/azure/kubernetesservice/aksLatestVersion.js +++ b/plugins/azure/kubernetesservice/aksLatestVersion.js @@ -9,7 +9,7 @@ module.exports = { description: 'Ensures the latest version of Kubernetes is installed on AKS clusters', more_info: 'AKS supports provisioning clusters from several versions of Kubernetes. Clusters should be kept up to date to ensure Kubernetes security patches are applied.', recommended_action: 'Upgrade the version of Kubernetes on all AKS clusters to the latest available version.', - link: 'https://learn.microsoft.com/en-us/azure/aks/aad-integration', + link: 'https://learn.microsoft.com/en-us/azure/aks/upgrade-aks-cluster?tabs=azure-portal', apis: ['managedClusters:list', 'managedClusters:getUpgradeProfile'], realtime_triggers: ['microsoftcontainerservice:managedclusters:write', 'microsoftcontainerservice:managedclusters:delete'], @@ -47,7 +47,7 @@ module.exports = { getUpgradeProfile.data.controlPlaneProfile.upgrades && getUpgradeProfile.data.controlPlaneProfile.upgrades.length) { helpers.addResult(results, 2, - `The managed cluster does not have the latest Kubernetes version: ${getUpgradeProfile.data.controlPlaneProfile.upgrades[0]}`, location, managedCluster.id); + `The managed cluster does not have the latest Kubernetes version: ${getUpgradeProfile.data.controlPlaneProfile.upgrades[0].kubernetesVersion}`, location, managedCluster.id); } else { helpers.addResult(results, 0, 'The managed cluster has the latest Kubernetes version', location, managedCluster.id); diff --git a/plugins/azure/kubernetesservice/aksLatestVersion.spec.js b/plugins/azure/kubernetesservice/aksLatestVersion.spec.js index 8043d85546..9d0db2b229 100644 --- a/plugins/azure/kubernetesservice/aksLatestVersion.spec.js +++ b/plugins/azure/kubernetesservice/aksLatestVersion.spec.js @@ -65,8 +65,8 @@ describe('aksLatestVersion', function() { "kubernetesVersion": "1.11.10", "osType": "Linux", "upgrades": [ - "1.12.7", - "1.12.8" + {"kubernetesVersion": "1.12.7"}, + {"kubernetesVersion": "1.12.8"} ] }, "agentPoolProfiles": [ diff --git a/plugins/azure/kubernetesservice/aksNetworkExposure.js b/plugins/azure/kubernetesservice/aksNetworkExposure.js new file mode 100644 index 0000000000..0dd8326feb --- /dev/null +++ b/plugins/azure/kubernetesservice/aksNetworkExposure.js @@ -0,0 +1,103 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Internet Exposure', + category: 'Kubernetes Service', + domain: 'Containers', + severity: 'High', + description: 'Ensures that Azure Kubernetes clusters are not exposed to the internet.', + more_info: 'In a private cluster, the control plane or API server has internal IP addresses that are defined in the RFC1918 - Address Allocation for Private Internet document. By using a private cluster, you can ensure network traffic between your API server and your node pools remains on the private network only.', + recommended_action: 'Modify cluster network configuration and enable private cluster feature.', + link: 'https://learn.microsoft.com/en-us/azure/aks/private-clusters', + apis: ['managedClusters:list', 'resourceGroups:list', 'resources:listByResourceGroup', 'networkSecurityGroups:listAll', 'virtualNetworks:listAll',], + realtime_triggers: ['microsoftcontainerservice:managedclusters:write', 'microsoftcontainerservice:managedclusters:delete', 'microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete', 'microsoftnetwork:virtualnetworks:write','microsoftnetwork:virtualnetworks:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.managedClusters, function(location, rcb) { + var managedClusters = helpers.addSource(cache, source, + ['managedClusters', 'list', location]); + + if (!managedClusters) return rcb(); + + if (managedClusters.err || !managedClusters.data) { + helpers.addResult(results, 3, + 'Unable to query for Kubernetes clusters: ' + helpers.addError(managedClusters), location); + return rcb(); + } + + if (!managedClusters.data.length) { + helpers.addResult(results, 0, 'No existing Kubernetes clusters', location); + return rcb(); + } + var resources = helpers.addSource(cache, source, + ['resources', 'listByResourceGroup', location]); + + let networkSecurityGroups = helpers.addSource(cache, source, + ['networkSecurityGroups', 'listAll', location]); + + var virtualNetworks = helpers.addSource(cache, source, + ['virtualNetworks', 'listAll', location]); + + for (let cluster of managedClusters.data) { + if (!cluster.id) continue; + + // check for api server access + let publicIPs = ['*', '0.0.0.0', '0.0.0.0/0', '', '/0', '::/0', 'internet']; + let internetExposed = ''; + if (cluster.apiServerAccessProfile && !cluster.apiServerAccessProfile.enablePrivateCluster) { + let authorizedIPRanges = cluster.apiServerAccessProfile.authorizedIpRanges; + if (!authorizedIPRanges || !authorizedIPRanges.length || authorizedIPRanges.some(range => publicIPs.includes(range))) { + internetExposed = 'public endpoint access'; + } + } + if (!internetExposed || !internetExposed.length) { + // check NSG rules for node pools + let securityGroupIDs = [], subnets = [], vnets = [], securityGroups = []; + + if (networkSecurityGroups && !networkSecurityGroups.err && networkSecurityGroups.data && networkSecurityGroups.data.length) { + if (virtualNetworks && !virtualNetworks.err && virtualNetworks.data && virtualNetworks.data.length) { + if (cluster.agentPoolProfiles && cluster.agentPoolProfiles.length) { + subnets = cluster.agentPoolProfiles.map(profile => profile.vnetSubnetId); + } + + if (cluster.nodeResourceGroup && resources && Object.keys(resources).length) { + let groupID = Object.keys(resources).find(key => key.toLowerCase().endsWith(cluster.nodeResourceGroup.toLowerCase())); + if (groupID && resources[groupID] && resources[groupID].data && resources[groupID].data.length) { + vnets = resources[groupID].data.filter(resource => resource.type === 'Microsoft.Network/virtualNetworks').map(vnet => vnet.id); + + } + } + + virtualNetworks.data.forEach(vnet => { + if (vnet.subnets && vnet.subnets.length) { + vnet.subnets.forEach(subnet => { + if ((subnets.includes(subnet.id) || vnets.includes(vnet.id)) && subnet.properties && subnet.properties.networkSecurityGroup && subnet.properties.networkSecurityGroup.id) { + securityGroupIDs.push(subnet.properties.networkSecurityGroup.id); + } + }); + } + }); + securityGroups = networkSecurityGroups.data.filter(nsg => securityGroupIDs.includes(nsg.id)); + internetExposed = helpers.checkNetworkExposure(cache, source, [], securityGroups, location, results, {}, cluster); + } + } + } + + if (internetExposed && internetExposed.length) { + helpers.addResult(results, 2, `AKS cluster is exposed to the internet through ${internetExposed}`, location, cluster.id); + } else { + helpers.addResult(results, 0, 'AKS cluster is not exposed to the internet', location, cluster.id); + } + } + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/kubernetesservice/aksPrivilegeAnalysis.js b/plugins/azure/kubernetesservice/aksPrivilegeAnalysis.js new file mode 100644 index 0000000000..266ffebf04 --- /dev/null +++ b/plugins/azure/kubernetesservice/aksPrivilegeAnalysis.js @@ -0,0 +1,24 @@ +module.exports = { + title: 'Privilege Analysis', + category: 'Kubernetes Service', + domain: 'Containers', + severity: 'Info', + description: 'Ensures that AKS clusters and workloads are not granted excessive permissions.', + more_info: 'AKS clusters often use managed identities to interact with Azure resources. Over-privileged identities can lead to privilege escalation or lateral movement within the cluster or the Azure environment. Following the principle of least privilege helps minimize potential attack surfaces.', + link: 'https://docs.microsoft.com/en-us/azure/aks/use-managed-identity', + recommended_action: 'Review and minimize Azure AD permissions granted to AKS managed identities and workload identities. Use Azure RBAC and Kubernetes RBAC best practices to ensure only required access is permitted.', + apis: [''], + realtime_triggers: [ + 'Microsoft.ContainerService/managedClusters/write', + 'Microsoft.ContainerService/managedClusters/delete', + 'Microsoft.ContainerService/managedClusters/agentPools/write', + 'Microsoft.ManagedIdentity/userAssignedIdentities/assign/action', + ], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + callback(null, results, source); + }, +}; diff --git a/plugins/azure/mediaServices/amsManagedIdentityEnabled.js b/plugins/azure/mediaServices/amsManagedIdentityEnabled.js index a35cb99a3b..8babd67ed3 100644 --- a/plugins/azure/mediaServices/amsManagedIdentityEnabled.js +++ b/plugins/azure/mediaServices/amsManagedIdentityEnabled.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Content Delivery', severity: 'Medium', description: 'Ensures that Azure Media Service 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.', link: 'https://learn.microsoft.com/en-us/azure/media-services/latest/concept-managed-identities', recommended_action: 'Create a new Media service account with managed identity for storage account enabled.', apis: ['mediaServices:listAll', 'mediaServices:get'], diff --git a/plugins/azure/mysqlserver/mysqlFlexibleServerIdentity.js b/plugins/azure/mysqlserver/mysqlFlexibleServerIdentity.js index a770cf0477..731acafe6a 100644 --- a/plugins/azure/mysqlserver/mysqlFlexibleServerIdentity.js +++ b/plugins/azure/mysqlserver/mysqlFlexibleServerIdentity.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Databases', severity: 'Medium', description: 'Ensures that MySQL flexible servers 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 MySQL flexible server add managed identity.', link: 'https://learn.microsoft.com/en-us/azure/mysql/flexible-server/how-to-azure-ad', apis: ['servers:listMysqlFlexibleServer'], diff --git a/plugins/azure/mysqlserver/mysqlFlexibleServerPublicAccess.js b/plugins/azure/mysqlserver/mysqlFlexibleServerPublicAccess.js index c61bbcdb32..68d5f4d836 100644 --- a/plugins/azure/mysqlserver/mysqlFlexibleServerPublicAccess.js +++ b/plugins/azure/mysqlserver/mysqlFlexibleServerPublicAccess.js @@ -6,24 +6,42 @@ module.exports = { category: 'MySQL Server', domain: 'Databases', severity: 'High', - description: 'Ensures that MySQL flexible servers are not publicly accessible.', - more_info: 'Configuring public access for MySQL flexible server instance allows the server to be accessible through public endpoint. This can expose the server to unauthorized access and various cyber threats. Disabling public access enhances security by limiting access to authorized connections only.', - recommended_action: 'Modify MySQL flexible server and disable public network access.', + description: 'Ensures that MySQL flexible servers do not allow public access', + more_info: 'Configuring public access for MySQL flexible server instance allows the server to be accessible through public endpoint. MySQL flexible server instances should not have a public endpoint and should only be accessed from within a VNET.', + recommended_action: 'Ensure that the firewall of each MySQL flexible server is configured to prohibit traffic from the public.', link: 'https://learn.microsoft.com/en-us/azure/mysql/flexible-server/concepts-networking-public', - apis: ['servers:listMysqlFlexibleServer'], - realtime_triggers: ['microsoftdbformysql:flexibleservers:write','microsoftdbformysql:flexibleservers:delete'], + apis: ['servers:listMysqlFlexibleServer', 'firewallRules:listByFlexibleServerMysql'], + settings: { + mysql_flexible_server_allowed_ips: { + name: 'MySQL Flexible Server Allowed IPs', + description: 'Comma-separated list of customer defined IP addresses/ranges that are allowed to access MySQL flexible servers.', + regex: '((25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(,\n|,?$))', + default: '' + } + }, + realtime_triggers: ['microsoftdbformysql:flexibleservers:write', 'microsoftdbformysql:flexibleservers:firewallrules:write', 'microsoftdbformysql:flexibleservers:firewallrules:delete', 'microsoftdbformysql:flexibleservers:delete'], run: function(cache, settings, callback) { const results = []; const source = {}; const locations = helpers.locations(settings.govcloud); + var config = { + mysql_flexible_server_allowed_ips: settings.mysql_flexible_server_allowed_ips || this.settings.mysql_flexible_server_allowed_ips.default + }; + + var allowedIps = []; + if (config.mysql_flexible_server_allowed_ips && config.mysql_flexible_server_allowed_ips.length > 0) { + allowedIps = config.mysql_flexible_server_allowed_ips.split(',').map(ip => ip.trim()); + } + var checkAllowedIps = allowedIps.length > 0; + async.each(locations.servers, (location, rcb) => { const servers = helpers.addSource(cache, source, ['servers', 'listMysqlFlexibleServer', location]); if (!servers) return rcb(); - + if (servers.err || !servers.data) { helpers.addResult(results, 3, 'Unable to query for MySQL flexible servers: ' + helpers.addError(servers), location); @@ -35,22 +53,61 @@ module.exports = { return rcb(); } - for (var flexibleServer of servers.data) { - if (!flexibleServer.id) continue; - - if (flexibleServer.properties && - flexibleServer.properties.network && - flexibleServer.properties.network.publicNetworkAccess && - flexibleServer.properties.network.publicNetworkAccess.toLowerCase() == 'enabled') { - helpers.addResult(results, 2, 'MySQL flexible server is publicly accessible', location, flexibleServer.id); + servers.data.forEach(function(server) { + if (!server.id) return; + + + if (server.network && + server.network.publicNetworkAccess && + server.network.publicNetworkAccess.toLowerCase() === 'disabled') { + helpers.addResult(results, 0, 'MySQL Flexible Server is protected from outside traffic', location, server.id); } else { - helpers.addResult(results, 0, 'MySQL flexible server is not publicly accessible', location, flexibleServer.id); + const firewallRules = helpers.addSource(cache, source, + ['firewallRules', 'listByFlexibleServerMysql', location, server.id]); + + if (!firewallRules || firewallRules.err || !firewallRules.data) { + helpers.addResult(results, 3, + 'Unable to query MySQL Flexible Server Firewall Rules: ' + helpers.addError(firewallRules), location, server.id); + } else { + if (!firewallRules.data.length) { + helpers.addResult(results, 0, 'No existing MySQL Flexible Server Firewall Rules found', location, server.id); + } else { + var publicAccess = false; + + firewallRules.data.forEach(firewallRule => { + const startIpAddr = firewallRule['startIpAddress']; + const endIpAddr = firewallRule['endIpAddress']; + + if (startIpAddr && startIpAddr.toString().indexOf('0.0.0.0') > -1) { + if (checkAllowedIps) { + if (endIpAddr && allowedIps.includes(endIpAddr.toString())) { + publicAccess = true; + } + } else { + if (endIpAddr && endIpAddr.toString() === '255.255.255.255') { + publicAccess = true; + } else if (endIpAddr && endIpAddr.toString() === '0.0.0.0') { + publicAccess = true; + } + } + } + }); + + + if (publicAccess) { + helpers.addResult(results, 2, 'The MySQL flexible server is open to outside traffic', location, server.id); + } else { + helpers.addResult(results, 0, 'The MySQL flexible server is protected from outside traffic', location, server.id); + } + } + } } - } + }); + rcb(); }, function() { // Global checking goes here callback(null, results, source); }); } -}; +}; \ No newline at end of file diff --git a/plugins/azure/mysqlserver/mysqlFlexibleServerPublicAccess.spec.js b/plugins/azure/mysqlserver/mysqlFlexibleServerPublicAccess.spec.js index 4f6ab77062..1345564c39 100644 --- a/plugins/azure/mysqlserver/mysqlFlexibleServerPublicAccess.spec.js +++ b/plugins/azure/mysqlserver/mysqlFlexibleServerPublicAccess.spec.js @@ -1,23 +1,89 @@ -var assert = require('assert'); var expect = require('chai').expect; -var auth = require('./mysqlFlexibleServerPublicAccess'); +var mysqlFlexibleServerPublicAccess = require('./mysqlFlexibleServerPublicAccess'); -const createCache = (err, list) => { - return { +const listMysqlFlexibleServer = [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server", + "type": "Microsoft.DBforMySQL/flexibleServers", + "network": { + "publicNetworkAccess": "Disabled" + } + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server-2", + "type": "Microsoft.DBforMySQL/flexibleServers", + "network": { + "publicNetworkAccess": "Enabled" + } + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server-3", + "type": "Microsoft.DBforMySQL/flexibleServers", + "network": { + "publicNetworkAccess": "Disabled" + } + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server-4", + "type": "Microsoft.DBforMySQL/flexibleServers" + } +]; + +const firewallRules = [ + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.DBforMySQL/flexibleServers/test-server/firewallRules/AllowAll", + "name": "AllowAll", + "startIpAddress": "0.0.0.0", + "endIpAddress": "255.255.255.255" + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.DBforMySQL/flexibleServers/test-server/firewallRules/AllowAllAlt", + "name": "AllowAllAlt", + "startIpAddress": "0.0.0.0", + "endIpAddress": "0.0.0.0" + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.DBforMySQL/flexibleServers/test-server/firewallRules/AllowedIP", + "name": "AllowedIP", + "startIpAddress": "192.168.1.1", + "endIpAddress": "192.168.1.1" + } +]; + +const createCache = (servers, rules, serversErr, rulesErr) => { + const cache = { servers: { listMysqlFlexibleServer: { 'eastus': { - err: err, - data: list + data: servers || [], + err: serversErr || null } } + }, + firewallRules: { + listByFlexibleServerMysql: { + 'eastus': {} + } } + }; + + if (servers && servers.length > 0) { + servers.forEach(server => { + if (server && server.id) { + cache.firewallRules.listByFlexibleServerMysql.eastus[server.id] = { + data: rules || [], + err: rulesErr || null + }; + } + }); } + + return cache; }; describe('mysqlFlexibleServerPublicAccess', function() { describe('run', function() { - it('should PASS if no existing servers found', function(done) { + it('should give passing result if no SQL servers found', function(done) { const callback = (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); @@ -27,112 +93,134 @@ describe('mysqlFlexibleServerPublicAccess', function() { }; const cache = createCache( - null, - [], - {} + [] ); - auth.run(cache, {}, callback); + mysqlFlexibleServerPublicAccess.run(cache, {}, callback); }); - it('should FAIL if MySQL server is not publicly accessible', function(done) { + it('should give passing result if no existing SQL Flexible Server Firewall Rules 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('MySQL flexible server is not publicly accessible'); + expect(results[0].message).to.include('No existing MySQL Flexible Server Firewall Rules found'); expect(results[0].region).to.equal('eastus'); done() }; const cache = createCache( - null, - [ - { - "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server", - "type": "Microsoft.DBforMySQL/flexibleServers", - "properties": { - "administratorLogin": "test", - "storage": { - "storageSizeGB": 20, - "iops": 360, - "autoGrow": "Enabled", - "autoIoScaling": "Enabled", - "storageSku": "Premium_LRS", - "logOnDisk": "Disabled" - }, - "version": "5.7", - "state": "Ready", - "fullyQualifiedDomainName": "test-flexibleserverr-mysql.mysql.database.azure.com", - "availabilityZone": "3", - "replicationRole": "None", - "replicaCapacity": 10, - "network": { - "publicNetworkAccess": "Disabled" - }, - } - } - ] + [listMysqlFlexibleServer[1]], + [] + ); - auth.run(cache, {}, callback); + mysqlFlexibleServerPublicAccess.run(cache, {}, callback); }); - it('should FAIL if MySQL server is publicly accessible', function(done) { + it('should give passing result if SQL Server has private network access disabled', 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('MySQL Flexible Server is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [listMysqlFlexibleServer[0]], + [] + ); + + mysqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give failing result if SQL Server is open to outside traffic', 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('MySQL flexible server is publicly accessible'); + expect(results[0].message).to.include('The MySQL flexible server is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [listMysqlFlexibleServer[1]], + [firewallRules[0]] + ); + + mysqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give passing result if SQL Server firewall does not allow public access', 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('The MySQL flexible server is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [listMysqlFlexibleServer[1]], + [firewallRules[2]] + ); + + mysqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give passing result if The SQL server is protected from outside traffic', 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('The MySQL flexible server is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done() + }; + + const cache = createCache( + [listMysqlFlexibleServer[1]], + [firewallRules[2]] + ); + + mysqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for SQL servers', 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 MySQL flexible servers'); expect(results[0].region).to.equal('eastus'); done() }; const cache = createCache( null, - [ - { - "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforMySQL/flexibleServers/test-server", - "type": "Microsoft.DBforMySQL/flexibleServers", - "properties": { - "administratorLogin": "test", - "storage": { - "storageSizeGB": 20, - "iops": 360, - "autoGrow": "Enabled", - "autoIoScaling": "Enabled", - "storageSku": "Premium_LRS", - "logOnDisk": "Disabled" - }, - "version": "5.7", - "state": "Ready", - "fullyQualifiedDomainName": "test-flexibleserverr-mysql.mysql.database.azure.com", - "availabilityZone": "3", - "replicationRole": "None", - "replicaCapacity": 10, - "network": { - "publicNetworkAccess": "Enabled" - }, - } - } - ], + [], + { message: 'unable to query servers'} + ); - auth.run(cache, {}, callback); + mysqlFlexibleServerPublicAccess.run(cache, {}, callback); }); - it('should UNKNOWN if unable to query for server', function(done) { + it('should give unknown result if Unable to query for server firewall rules', 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 MySQL flexible servers: '); + expect(results[0].message).to.include('Unable to query MySQL Flexible Server Firewall Rules'); expect(results[0].region).to.equal('eastus'); done() }; const cache = createCache( - null, null + [listMysqlFlexibleServer[1]], + [], + null, + { message: 'Unable to query for server firewall rules'} ); - auth.run(cache, {}, callback); - }) + mysqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); }) }) \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/activeDirectoryAdminEnabled.js b/plugins/azure/postgresqlserver/activeDirectoryAdminEnabled.js index 7e3a0bd27d..debdec24c1 100644 --- a/plugins/azure/postgresqlserver/activeDirectoryAdminEnabled.js +++ b/plugins/azure/postgresqlserver/activeDirectoryAdminEnabled.js @@ -2,13 +2,13 @@ const async = require('async'); const helpers = require('../../../helpers/azure'); module.exports = { - title: 'Azure Active Directory Admin Configured', + title: 'Azure Entra ID Admin Configured', category: 'PostgreSQL Server', domain: 'Databases', severity: 'Medium', - description: 'Ensures that Active Directory admin is set up on all PostgreSQL servers.', - more_info: 'Using Azure Active Directory authentication allows key rotation and permission management to be managed in one location for all servers. This can be done are configuring an Active Directory administrator.', - recommended_action: 'Set up an Active Directory admin for PostgreSQL database servers.', + description: 'Ensures that Entra ID admin is set up on all PostgreSQL servers.', + more_info: 'Using Azure Entra ID authentication allows key rotation and permission management to be managed in one location for all servers. This can be done are configuring an Entra ID administrator.', + recommended_action: 'Set up an Entra ID admin for PostgreSQL database servers.', link: 'https://learn.microsoft.com/en-us/azure/postgresql/howto-configure-sign-in-aad-authentication', apis: ['servers:listPostgres', 'serverAdministrators:list'], realtime_triggers: ['microsoftdbforpostgresql:servers:write','microsoftdbforpostgresql:servers:delete'], @@ -42,25 +42,25 @@ module.exports = { if (!serverAdministrators || serverAdministrators.err || !serverAdministrators.data) { helpers.addResult(results, 3, - 'Unable to query for Active Directory admins: ' + helpers.addError(serverAdministrators), location, postgresServer.id); + 'Unable to query for Entra ID admins: ' + helpers.addError(serverAdministrators), location, postgresServer.id); } else { if (!serverAdministrators.data.length) { - helpers.addResult(results, 2, 'No Active Directory admin found for the server', location, postgresServer.id); + helpers.addResult(results, 2, 'No Entra ID admin found for the server', location, postgresServer.id); } else { - var adAdminEnabled = false; + var entraIdAdminEnabled = false; serverAdministrators.data.forEach(serverAdministrator => { if (serverAdministrator.name && serverAdministrator.name.toLowerCase() === 'activedirectory') { - adAdminEnabled = true; + entraIdAdminEnabled = true; } }); - if (adAdminEnabled) { + if (entraIdAdminEnabled) { helpers.addResult(results, 0, - 'Active Directory admin is enabled on the PostgreSQL server', location, postgresServer.id); + 'Entra ID admin is enabled on the PostgreSQL server', location, postgresServer.id); } else { helpers.addResult(results, 2, - 'Active Directory admin is not enabled on the PostgreSQL server', location, postgresServer.id); + 'Entra ID admin is not enabled on the PostgreSQL server', location, postgresServer.id); } } } diff --git a/plugins/azure/postgresqlserver/activeDirectoryAdminEnabled.spec.js b/plugins/azure/postgresqlserver/activeDirectoryAdminEnabled.spec.js index ccc8c6e698..e3c7227ccd 100644 --- a/plugins/azure/postgresqlserver/activeDirectoryAdminEnabled.spec.js +++ b/plugins/azure/postgresqlserver/activeDirectoryAdminEnabled.spec.js @@ -66,11 +66,11 @@ describe('activeDirectoryAdminEnabled', function() { activeDirectoryAdminEnabled.run(cache, {}, callback); }) - it('should give failing result if Active Directory admin is not enabled on the PostgreSQL server', function(done) { + it('should give failing result if Entra ID admin is not enabled on the PostgreSQL server', 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('Active Directory admin is not enabled on the PostgreSQL server'); + expect(results[0].message).to.include('Entra ID admin is not enabled on the PostgreSQL server'); expect(results[0].region).to.equal('eastus'); done() }; @@ -84,11 +84,11 @@ describe('activeDirectoryAdminEnabled', function() { activeDirectoryAdminEnabled.run(cache, {}, callback); }); - it('should give failing result if No Active Directory admin found for the server', function(done) { + it('should give failing result if No Entra ID admin found for the server', 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('No Active Directory admin found for the server'); + expect(results[0].message).to.include('No Entra ID admin found for the server'); expect(results[0].region).to.equal('eastus'); done() }; @@ -102,11 +102,11 @@ describe('activeDirectoryAdminEnabled', function() { activeDirectoryAdminEnabled.run(cache, {}, callback); }); - it('should give passing result if Active Directory admin is enabled on the PostgreSQL server', function(done) { + it('should give passing result if Entra ID admin is enabled on the PostgreSQL server', 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('Active Directory admin is enabled on the PostgreSQL server'); + expect(results[0].message).to.include('Entra ID admin is enabled on the PostgreSQL server'); expect(results[0].region).to.equal('eastus'); done() }; diff --git a/plugins/azure/postgresqlserver/postgresqlFlexibleServerPublicAccess.js b/plugins/azure/postgresqlserver/postgresqlFlexibleServerPublicAccess.js new file mode 100644 index 0000000000..52b39bd278 --- /dev/null +++ b/plugins/azure/postgresqlserver/postgresqlFlexibleServerPublicAccess.js @@ -0,0 +1,106 @@ +var async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL Flexible Server Public Access', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'High', + description: 'Ensures that PostgreSQL flexible servers do not allow public access', + more_info: 'Configuring public access for PostgreSQL flexible server instance allows the server to be accessible through public endpoint. PostgreSQL flexible server instances should not have a public endpoint and should only be accessed from within a VNET.', + recommended_action: 'Ensure that the firewall of each PostgreSQL flexible server is configured to prohibit traffic from the public address.', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-firewall-rules', + apis: ['servers:listPostgresFlexibleServer', 'firewallRules:listByFlexibleServerPostgres'], + settings: { + server_firewall_end_ip: { + name: 'PostgreSQL Server Firewall Rule End IP', + description: 'Comma separated list of IP addresses which cannot be end IPs for firewall rule', + regex: '((25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(,\n|,?$))', + default: '' + } + }, + realtime_triggers: ['microsoftdbforpostgresql:flexibleservers:write', 'microsoftdbforpostgresql:flexibleservers:firewallrules:write', 'microsoftdbforpostgresql:flexibleservers:firewallrules:delete', 'microsoftdbforpostgresql:flexibleservers:delete'], + + run: function(cache, settings, callback) { + + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + var config = { + server_firewall_end_ip: settings.server_firewall_end_ip || this.settings.server_firewall_end_ip.default + }; + + var checkEndIp = (config.server_firewall_end_ip.length > 0); + + async.each(locations.servers, function(location, rcb) { + + var servers = helpers.addSource(cache, source, + ['servers', 'listPostgresFlexibleServer', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL flexible servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL flexible servers found', location); + return rcb(); + } + + servers.data.forEach(function(server) { + + if (server.network && server.network.publicNetworkAccess && server.network.publicNetworkAccess.toLowerCase() === 'disabled') { + helpers.addResult(results, 0, 'The PostgreSQL flexible server has public network access disabled', location, server.id); + + } else { + const firewallRules = helpers.addSource(cache, source, + ['firewallRules', 'listByFlexibleServerPostgres', location, server.id]); + + if (!firewallRules || firewallRules.err || !firewallRules.data) { + helpers.addResult(results, 3, + 'Unable to query PostgreSQL Flexible Server Firewall Rules: ' + helpers.addError(firewallRules), location, server.id); + } else { + if (!firewallRules.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL Flexible Server Firewall Rules found', location, server.id); + } else { + var publicAccess = false; + + firewallRules.data.forEach(firewallRule => { + const startIpAddr = firewallRule['startIpAddress']; + const endIpAddr = firewallRule['endIpAddress']; + + if (startIpAddr && endIpAddr) { + if (checkEndIp) { + if (startIpAddr.toString().indexOf('0.0.0.0') > -1 && + config.server_firewall_end_ip.includes(endIpAddr.toString())) { + publicAccess = true; + } + } else if (startIpAddr.toString() === '0.0.0.0' && + (endIpAddr.toString() === '255.255.255.255' || endIpAddr.toString() === '0.0.0.0')) { + publicAccess = true; + } + } + }); + + if (publicAccess) { + helpers.addResult(results, 2, 'The PostgreSQL flexible server is open to outside traffic', location, server.id); + } else { + helpers.addResult(results, 0, 'The PostgreSQL flexible server is protected from outside traffic', location, server.id); + } + } + } + + } + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/postgresqlFlexibleServerPublicAccess.spec.js b/plugins/azure/postgresqlserver/postgresqlFlexibleServerPublicAccess.spec.js new file mode 100644 index 0000000000..72101d942c --- /dev/null +++ b/plugins/azure/postgresqlserver/postgresqlFlexibleServerPublicAccess.spec.js @@ -0,0 +1,218 @@ +var expect = require('chai').expect; +var postgresqlFlexibleServerPublicAccess = require('./postgresqlFlexibleServerPublicAccess'); + +const listPostgresFlexibleServer = [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server", + "type": "Microsoft.DBforPostgreSQL/flexibleServers", + "network": { + "publicNetworkAccess": "Disabled" + } + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server-2", + "type": "Microsoft.DBforPostgreSQL/flexibleServers", + "network": { + "publicNetworkAccess": "Enabled" + } + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server-3", + "type": "Microsoft.DBforPostgreSQL/flexibleServers" + } +]; + +const firewallRules = [ + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server/firewallRules/AllowAll", + "name": "AllowAll", + "startIpAddress": "0.0.0.0", + "endIpAddress": "255.255.255.255" + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server/firewallRules/AllowAllAlt", + "name": "AllowAllAlt", + "startIpAddress": "0.0.0.0", + "endIpAddress": "0.0.0.0" + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server/firewallRules/AllowIPv6", + "name": "AllowIPv6", + "startIpAddress": "::", + "endIpAddress": "::" + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server/firewallRules/AllowIPv6Alt", + "name": "AllowIPv6Alt", + "startIpAddress": "::/0", + "endIpAddress": "::/0" + }, + { + "id": "/subscriptions/123/resourceGroups/test/providers/Microsoft.DBforPostgreSQL/flexibleServers/test-server/firewallRules/RestrictedIP", + "name": "RestrictedIP", + "startIpAddress": "0.0.0.0", + "endIpAddress": "192.168.1.1" + } +]; + +const createCache = (servers, rules1) => { + const serverId1 = (servers && servers.length > 0) ? servers[0].id : null; + + const cache = { + servers: { + listPostgresFlexibleServer: { + 'eastus': { + data: servers + } + } + }, + firewallRules: { + listByFlexibleServerPostgres: { + 'eastus': {} + } + } + }; + + if (serverId1) { + cache.firewallRules.listByFlexibleServerPostgres.eastus[serverId1] = { + data: rules1 || [] + }; + } + + return cache; +}; + +describe('postgresqlFlexibleServerPublicAccess', function() { + describe('run', function() { + it('should give passing result if no servers', 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 existing PostgreSQL flexible servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([]); + postgresqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give passing result if server has public network access disabled', 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('The PostgreSQL flexible server has public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresFlexibleServer[0]]); + postgresqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give passing result if server has public access enabled but no firewall rules', 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 existing PostgreSQL Flexible Server Firewall Rules found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresFlexibleServer[1]], []); + postgresqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give failing result if server has firewall rule with restricted end IP', 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('The PostgreSQL flexible server is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresFlexibleServer[1]], [firewallRules[4]]); + postgresqlFlexibleServerPublicAccess.run(cache, {server_firewall_end_ip: '192.168.1.1'}, callback); + }); + + it('should give failing result if server has firewall rule allowing 0.0.0.0/0 access (full range)', 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('The PostgreSQL flexible server is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresFlexibleServer[1]], [firewallRules[0]]); + postgresqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give failing result if server has firewall rule allowing 0.0.0.0/0 access (0.0.0.0-0.0.0.0)', 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('The PostgreSQL flexible server is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresFlexibleServer[1]], [firewallRules[1]]); + postgresqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for PostgreSQL Servers', 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 PostgreSQL flexible servers'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = { + servers: { + listPostgresFlexibleServer: { + 'eastus': { + err: 'Error querying servers' + } + } + } + }; + + postgresqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query firewall rules', 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 PostgreSQL Flexible Server Firewall Rules'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = { + servers: { + listPostgresFlexibleServer: { + 'eastus': { + data: [listPostgresFlexibleServer[1]] + } + } + }, + firewallRules: { + listByFlexibleServerPostgres: { + 'eastus': { + [listPostgresFlexibleServer[1].id]: { + err: 'Error querying firewall rules' + } + } + } + } + }; + + postgresqlFlexibleServerPublicAccess.run(cache, {}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/postgresqlServerPublicAccess.js b/plugins/azure/postgresqlserver/postgresqlServerPublicAccess.js new file mode 100644 index 0000000000..4e8f896334 --- /dev/null +++ b/plugins/azure/postgresqlserver/postgresqlServerPublicAccess.js @@ -0,0 +1,109 @@ +const async = require('async'); +const helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'PostgreSQL Server Public Access', + category: 'PostgreSQL Server', + domain: 'Databases', + severity: 'High', + description: 'Ensures that PostgreSQL servers do not allow public access', + more_info: 'Configuring public access for PostgreSQL server instance allows the server to be accessible through public endpoint. PostgreSQL server server instances should not have a public endpoint and should only be accessed from within a VNET.', + recommended_action: 'Ensure that the firewall of each PostgreSQL server is configured to prohibit traffic from the public address.', + link: 'https://learn.microsoft.com/en-us/azure/postgresql/concepts-firewall-rules', + apis: ['servers:listPostgres', 'firewallRules:listByServerPostgres'], + settings: { + postgresql_server_allowed_ips: { + name: 'PostgreSQL Server Allowed IPs', + description: 'Comma-separated list of customer defined IP addresses/ranges that are allowed to access PostgreSQL servers', + regex: '((25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(25[0-5]|2[0-4]|[01]??)(,\n|,?$))', + default: '' + } + }, + realtime_triggers: ['microsoftdbforpostgresql:servers:write', 'microsoftdbforpostgresql:servers:firewallrules:write', 'microsoftdbforpostgresql:servers:firewallrules:delete', 'microsoftdbforpostgresql:servers:delete'], + + run: function(cache, settings, callback) { + const results = []; + const source = {}; + const locations = helpers.locations(settings.govcloud); + var config = { + postgresql_server_allowed_ips: settings.postgresql_server_allowed_ips || this.settings.postgresql_server_allowed_ips.default + }; + + var allowedIps = []; + if (config.postgresql_server_allowed_ips && config.postgresql_server_allowed_ips.length > 0) { + allowedIps = config.postgresql_server_allowed_ips.split(',').map(ip => ip.trim()); + } + var checkAllowedIps = allowedIps.length > 0; + + async.each(locations.servers, (location, rcb) => { + const servers = helpers.addSource(cache, source, + ['servers', 'listPostgres', location]); + + if (!servers) return rcb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for PostgreSQL servers: ' + helpers.addError(servers), location); + return rcb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL servers found', location); + return rcb(); + } + + servers.data.forEach(function(server) { + if (!server.id) return; + + if (server.publicNetworkAccess && server.publicNetworkAccess.toLowerCase() === 'disabled') { + helpers.addResult(results, 0, 'The PostgreSQL server has public network access disabled', location, server.id); + } else { + const firewallRules = helpers.addSource(cache, source, + ['firewallRules', 'listByServerPostgres', location, server.id]); + + if (!firewallRules || firewallRules.err || !firewallRules.data) { + helpers.addResult(results, 3, + 'Unable to query PostgreSQL Server Firewall Rules: ' + helpers.addError(firewallRules), location, server.id); + } else { + if (!firewallRules.data.length) { + helpers.addResult(results, 0, 'No existing PostgreSQL Server Firewall Rules found', location, server.id); + } else { + var publicAccess = false; + + firewallRules.data.forEach(firewallRule => { + const startIpAddr = firewallRule['startIpAddress']; + const endIpAddr = firewallRule['endIpAddress']; + + if (startIpAddr && startIpAddr.toString().indexOf('0.0.0.0') > -1) { + if (checkAllowedIps) { + if (endIpAddr && allowedIps.includes(endIpAddr.toString())) { + publicAccess = true; + } + } else { + if (endIpAddr && endIpAddr.toString() === '255.255.255.255') { + publicAccess = true; + } else if (endIpAddr && endIpAddr.toString() === '0.0.0.0') { + publicAccess = true; + } + } + } + }); + + if (publicAccess) { + helpers.addResult(results, 2, 'The PostgreSQL server is open to outside traffic', location, server.id); + } else { + helpers.addResult(results, 0, 'The PostgreSQL server is protected from outside traffic', location, server.id); + } + } + } + } + + }); + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; \ No newline at end of file diff --git a/plugins/azure/postgresqlserver/postgresqlServerPublicAccess.spec.js b/plugins/azure/postgresqlserver/postgresqlServerPublicAccess.spec.js new file mode 100644 index 0000000000..e8a9ab94b8 --- /dev/null +++ b/plugins/azure/postgresqlserver/postgresqlServerPublicAccess.spec.js @@ -0,0 +1,298 @@ +var expect = require('chai').expect; +var postgresqlServerPublicAccess = require('./postgresqlServerPublicAccess'); + +const listPostgresServer = [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/servers/test-server", + "type": "Microsoft.DBforPostgreSQL/servers", + "publicNetworkAccess": "Disabled" + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/servers/test-server-2", + "type": "Microsoft.DBforPostgreSQL/servers", + "publicNetworkAccess": "Enabled" + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/servers/test-server-3", + "type": "Microsoft.DBforPostgreSQL/servers" + } +]; + +const firewallRules = [ + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/servers/test-server/firewallRules/TestRule", + "name": "TestRule", + "type": "Microsoft.DBforPostgreSQL/servers/firewallRules", + "startIpAddress": "192.168.1.1", + "endIpAddress": "192.168.1.10" + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/servers/test-server/firewallRules/AllowAll", + "name": "AllowAll", + "type": "Microsoft.DBforPostgreSQL/servers/firewallRules", + "startIpAddress": "0.0.0.0", + "endIpAddress": "255.255.255.255" + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/servers/test-server/firewallRules/AllowAllAlt", + "name": "AllowAllAlt", + "type": "Microsoft.DBforPostgreSQL/servers/firewallRules", + "startIpAddress": "0.0.0.0", + "endIpAddress": "0.0.0.0" + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/servers/test-server/firewallRules/CustomerIP", + "name": "CustomerIP", + "type": "Microsoft.DBforPostgreSQL/servers/firewallRules", + "startIpAddress": "10.0.0.1", + "endIpAddress": "10.0.0.1" + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/servers/test-server/firewallRules/AllowAllWindowsAzureIPs", + "name": "AllowAllWindowsAzureIPs", + "type": "Microsoft.DBforPostgreSQL/servers/firewallRules", + "startIpAddress": "0.0.0.0", + "endIpAddress": "0.0.0.0" + }, + { + "id": "/subscriptions/12345/resourceGroups/Default/providers/Microsoft.DBforPostgreSQL/servers/test-server/firewallRules/CustomerDefinedRule", + "name": "CustomerDefinedRule", + "type": "Microsoft.DBforPostgreSQL/servers/firewallRules", + "startIpAddress": "0.0.0.0", + "endIpAddress": "10.0.0.1" + } +]; + +const createCache = (servers, rules1, rules2) => { + const serverId1 = (servers && servers.length > 0) ? servers[0].id : null; + const serverId2 = (servers && servers.length > 1) ? servers[1].id : null; + + const cache = { + servers: { + listPostgres: { + 'eastus': { + data: servers + } + } + }, + firewallRules: { + listByServerPostgres: { + 'eastus': {} + } + } + }; + + if (serverId1) { + cache.firewallRules.listByServerPostgres.eastus[serverId1] = { + data: rules1 || [] + }; + } + + if (serverId2) { + cache.firewallRules.listByServerPostgres.eastus[serverId2] = { + data: rules2 || [] + }; + } + + return cache; +}; + +describe('postgresqlServerPublicAccess', function() { + describe('run', function() { + it('should give passing result if no servers', 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 existing PostgreSQL servers found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([]); + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should give passing result if server has public network access disabled', 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('The PostgreSQL server has public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresServer[0]]); + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should give passing result if server has public access enabled but no firewall rules', 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 existing PostgreSQL Server Firewall Rules found'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresServer[1]], []); + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should give passing result if server has public access enabled but restrictive firewall rules', 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('The PostgreSQL server is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresServer[1]], [firewallRules[0]]); + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should give failing result if server has firewall rule allowing 0.0.0.0/0 access (full range)', 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('The PostgreSQL server is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresServer[1]], [firewallRules[1]]); + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should give failing result if server has firewall rule allowing 0.0.0.0/0 access (0.0.0.0-0.0.0.0)', 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('The PostgreSQL server is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresServer[1]], [firewallRules[2]]); + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should give failing result if server has AllowAllWindowsAzureIPs firewall rule', 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('The PostgreSQL server is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresServer[1]], [firewallRules[4]]); + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should give passing result if server has customer defined IP in firewall rules', 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('The PostgreSQL server is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresServer[1]], [firewallRules[3]]); + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for PostgreSQL Servers', 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 PostgreSQL servers'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = { + servers: { + listPostgres: { + 'eastus': { + err: 'Error querying servers' + } + } + } + }; + + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query firewall rules', 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 PostgreSQL Server Firewall Rules'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = { + servers: { + listPostgres: { + 'eastus': { + data: [listPostgresServer[1]] + } + } + }, + firewallRules: { + listByServerPostgres: { + 'eastus': { + [listPostgresServer[1].id]: { + err: 'Error querying firewall rules' + } + } + } + } + }; + + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should check firewall rules if server has no publicNetworkAccess property', 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('The PostgreSQL server is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresServer[2]], [firewallRules[0]]); + postgresqlServerPublicAccess.run(cache, {}, callback); + }); + + it('should give failing result if server has firewall rule with customer defined IP as end address', 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('The PostgreSQL server is open to outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresServer[2]], [firewallRules[5]]); + postgresqlServerPublicAccess.run(cache, {postgresql_server_allowed_ips: '10.0.0.1'}, callback); + }); + + it('should give passing result if server has firewall rule with customer defined IP but not matching allowed IPs', 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('The PostgreSQL server is protected from outside traffic'); + expect(results[0].region).to.equal('eastus'); + done(); + }; + + const cache = createCache([listPostgresServer[2]], [firewallRules[5]]); + postgresqlServerPublicAccess.run(cache, {postgresql_server_allowed_ips: '192.168.1.1'}, callback); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/redisCache/redisCacheManagedIdentity.js b/plugins/azure/redisCache/redisCacheManagedIdentity.js index 2f79fcbc2b..f6a3947af7 100644 --- a/plugins/azure/redisCache/redisCacheManagedIdentity.js +++ b/plugins/azure/redisCache/redisCacheManagedIdentity.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Databases', severity: 'Medium', description: 'Ensures that Azure Cache for Redis 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 Azure Cache for Redis and add managed identity.', link: 'https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-managed-identity#enable-managed-identity', apis: ['redisCaches:listBySubscription'], diff --git a/plugins/azure/servicebus/namespaceLocalAuth.js b/plugins/azure/servicebus/namespaceLocalAuth.js index a370363927..c85df38ba0 100644 --- a/plugins/azure/servicebus/namespaceLocalAuth.js +++ b/plugins/azure/servicebus/namespaceLocalAuth.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Application Integration', severity: 'Low', description: 'Ensures local authentication is disabled for Service Bus namespaces.', - 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 (shared access policies) in Azure Service Bus namespaces.', + 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 (shared access policies) in Azure Service Bus namespaces.', recommended_action: 'Ensure that Azure Service Bus namespaces have local authentication disabled.', link: 'https://learn.microsoft.com/en-us/azure/service-bus-messaging/disable-local-authentication', apis: ['serviceBus:listNamespacesBySubscription'], diff --git a/plugins/azure/servicebus/namespaceManagedIdentity.js b/plugins/azure/servicebus/namespaceManagedIdentity.js index c4a3a8abda..a6018ac702 100644 --- a/plugins/azure/servicebus/namespaceManagedIdentity.js +++ b/plugins/azure/servicebus/namespaceManagedIdentity.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Application Integration', severity: 'Medium', description: 'Ensure that Azure Service Bus namespaces 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 Service Bus namespace and enable managed identity.', link: 'https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-managed-service-identity', apis: ['serviceBus:listNamespacesBySubscription'], diff --git a/plugins/azure/sqldatabases/dbTDEEnabled.js b/plugins/azure/sqldatabases/dbTDEEnabled.js index 1352a00de8..f8a929b4c5 100644 --- a/plugins/azure/sqldatabases/dbTDEEnabled.js +++ b/plugins/azure/sqldatabases/dbTDEEnabled.js @@ -7,10 +7,10 @@ module.exports = { domain: 'Databases', severity: 'Medium', description: 'Ensure that Transparent Data Encryption (TDE) is enabled for SQL databases.', - more_info: 'Transparent data encryption (TDE) helps protect Azure SQL Database, Managed Instance, and Synapse Analytics against the threat of malicious offline activity by encrypting data at rest. It performs real-time encryption and decryption of the database, associated backups, and transaction log files at rest without requiring changes to the application.', + more_info: 'Transparent data encryption (TDE) helps protect Azure SQL Databases, Managed Instances, and Synapse Analytics against the threat of malicious offline activity by encrypting data at rest. It performs real-time encryption and decryption of the database, associated backups, and transaction log files at rest without requiring changes to the application.', recommended_action: 'Modify SQL database and enable Transparent Data Encryption (TDE).', link: 'https://docs.microsoft.com/en-us/sql/relational-databases/security/encryption/transparent-data-encryption?view=sql-server-ver15', - apis: ['servers:listSql', 'databases:listByServer', 'transparentDataEncryption:list'], + apis: ['servers:listSql', 'databases:listByServer', 'transparentDataEncryption:list', 'managedInstances:list', 'managedDatabases:listByInstance'], realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:databases:write', 'microsoftsql:servers:databases:transparentdataencryption:write', 'microsoftsql:servers:databases:delete'], run: function(cache, settings, callback) { @@ -19,55 +19,107 @@ module.exports = { var locations = helpers.locations(settings.govcloud); async.each(locations.servers, function(location, rcb) { - var servers = helpers.addSource(cache, source, ['servers', 'listSql', location]); - - if (!servers) return rcb(); - - if (servers.err || !servers.data) { - helpers.addResult(results, 3, 'Unable to query for SQL servers: ' + helpers.addError(servers), location); - return rcb(); - } - - if (!servers.data.length) { - helpers.addResult(results, 0, 'No SQL servers found', location); - return rcb(); - } - - servers.data.forEach(server => { - var databases = helpers.addSource(cache, source, - ['databases', 'listByServer', location, server.id]); - - if (!databases || databases.err || !databases.data) { - helpers.addResult(results, 3, - 'Unable to query for SQL server databases: ' + helpers.addError(databases), location, server.id); - } else { - if (!databases.data.length) { - helpers.addResult(results, 0, - 'No databases found for SQL server', location, server.id); - } else { - databases.data.forEach(database => { - - if (database.name && database.name.toLowerCase() !== 'master') { - var transparentDataEncryption = helpers.addSource(cache, source, ['transparentDataEncryption', 'list', location, database.id]); - - if (!transparentDataEncryption || transparentDataEncryption.err || !transparentDataEncryption.data || !transparentDataEncryption.data.length) { - helpers.addResult(results, 3, 'Unable to query transparent data encryption for SQL Database: ' + helpers.addError(transparentDataEncryption), location, database.id); - return; - } - var encryption = transparentDataEncryption.data[0]; - if (encryption.state && encryption.state.toLowerCase() == 'enabled') { - helpers.addResult(results, 0, 'Transparent data encryption is enabled for SQL Database', location, database.id); - } else { - helpers.addResult(results, 2, 'Transparent data encryption is not enabled for SQL Database', location, database.id); - } + async.parallel([ + // Check SQL Server Databases + function(cb) { + const servers = helpers.addSource(cache, source, ['servers', 'listSql', location]); + + if (!servers) return cb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return cb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return cb(); + } + + servers.data.forEach(server => { + var databases = helpers.addSource(cache, source, + ['databases', 'listByServer', location, server.id]); + + if (!databases || databases.err || !databases.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL server databases: ' + helpers.addError(databases), location, server.id); + } else { + if (!databases.data.length) { + helpers.addResult(results, 0, + 'No databases found for SQL server', location, server.id); + } else { + databases.data.forEach(database => { + + if (database.name && database.name.toLowerCase() !== 'master') { + var transparentDataEncryption = helpers.addSource(cache, source, + ['transparentDataEncryption', 'list', location, database.id]); + + if (!transparentDataEncryption || transparentDataEncryption.err || + !transparentDataEncryption.data || !transparentDataEncryption.data.length) { + helpers.addResult(results, 3, 'Unable to query transparent data encryption for SQL Database: ' + helpers.addError(transparentDataEncryption), location, database.id); + return; + } + var encryption = transparentDataEncryption.data[0]; + if (encryption.state && encryption.state.toLowerCase() == 'enabled') { + helpers.addResult(results, 0, + 'SQL Database: Transparent data encryption is enabled', location, database.id); + } else { + helpers.addResult(results, 2, + 'SQL Database: Transparent data encryption is not enabled', location, database.id); + } + } + }); } - }); + } + + }); + + cb(); + }, + // Check Managed Instances + function(cb) { + const managedInstances = helpers.addSource(cache, source, + ['managedInstances', 'list', location]); + + if (!managedInstances) return cb(); + + if (managedInstances.err || !managedInstances.data) { + helpers.addResult(results, 3, + 'Unable to query for managed instances: ' + helpers.addError(managedInstances), location); + return cb(); } - } - }); + if (!managedInstances.data.length) { + helpers.addResult(results, 0, 'No managed instances found', location); + return cb(); + } - rcb(); + managedInstances.data.forEach(instance => { + const managedDatabases = helpers.addSource(cache, source, + ['managedDatabases', 'listByInstance', location, instance.id]); + + if (!managedDatabases || managedDatabases.err || !managedDatabases.data) { + helpers.addResult(results, 3, + 'Unable to query for managed instance databases: ' + helpers.addError(managedDatabases), location, instance.id); + } else if (!managedDatabases.data.length) { + helpers.addResult(results, 0, + 'No databases found for managed instance', location, instance.id); + } else { + managedDatabases.data.forEach(database => { + if (database.name && database.name.toLowerCase() !== 'master') { + // Managed instances have TDE enabled by default and cannot be disabled + helpers.addResult(results, 0, + 'Managed Instance Database: Transparent data encryption is enabled', location, database.id); + } + }); + } + }); + + cb(); + } + ], function() { + rcb(); + }); }, function() { callback(null, results, source); }); diff --git a/plugins/azure/sqldatabases/dbTDEEnabled.spec.js b/plugins/azure/sqldatabases/dbTDEEnabled.spec.js index f85fca17ed..a21a6218e0 100644 --- a/plugins/azure/sqldatabases/dbTDEEnabled.spec.js +++ b/plugins/azure/sqldatabases/dbTDEEnabled.spec.js @@ -1,5 +1,5 @@ -var expect = require('chai').expect; -var enableTransparentDataEncryption = require('./dbTDEEnabled'); +const expect = require('chai').expect; +const enableTransparentDataEncryption = require('./dbTDEEnabled'); const servers = [ { @@ -7,6 +7,13 @@ const servers = [ } ]; +const managedInstances = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/managedInstances/test-instance", + "name": "test-instance" + } +]; + const databases = [ { "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database", @@ -14,6 +21,13 @@ const databases = [ } ]; +const managedDatabases = [ + { + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/managedInstances/test-instance/databases/test-database", + "name": "test-database" + } +]; + const transparentDataEncryptionEnabled = [ { "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database/transparentDataEncryption/1", @@ -21,9 +35,10 @@ const transparentDataEncryptionEnabled = [ } ]; -const createCache = (servers, databases, transparentDataEncryption, serversErr, databasesErr, transparentDataEncryptionErr) => { +const createCache = (servers, databases, transparentDataEncryption, managedInstances, managedDatabases, serversErr, databasesErr, transparentDataEncryptionErr, managedInstancesErr, managedDatabasesErr) => { const serverId = (servers && servers.length) ? servers[0].id : null; const databaseId = (databases && databases.length) ? databases[0].id : null; + const managedInstanceId = (managedInstances && managedInstances.length) ? managedInstances[0].id : null; return { servers: { listSql: { @@ -52,6 +67,24 @@ const createCache = (servers, databases, transparentDataEncryption, serversErr, } } } + }, + managedInstances: { + list: { + 'eastus': { + err: managedInstancesErr, + data: managedInstances + } + } + }, + managedDatabases: { + listByInstance: { + 'eastus': { + [managedInstanceId]: { + err: managedDatabasesErr, + data: managedDatabases + } + } + } } }; }; @@ -60,7 +93,7 @@ describe('enableTransparentDataEncryption', function() { describe('run', function() { it('should give passing result if no SQL servers found', function(done) { const callback = (err, results) => { - expect(results.length).to.equal(1); + expect(results.length).to.equal(2); expect(results[0].status).to.equal(0); expect(results[0].message).to.include('No SQL servers found'); expect(results[0].region).to.equal('eastus'); @@ -68,9 +101,8 @@ describe('enableTransparentDataEncryption', function() { }; const cache = createCache( - [], - databases, - transparentDataEncryptionEnabled + [], databases, transparentDataEncryptionEnabled, + [], [] ); enableTransparentDataEncryption.run(cache, {}, callback); @@ -78,7 +110,7 @@ describe('enableTransparentDataEncryption', function() { it('should give passing result if no databases found for SQL server', function(done) { const callback = (err, results) => { - expect(results.length).to.equal(1); + expect(results.length).to.equal(2); expect(results[0].status).to.equal(0); expect(results[0].message).to.include('No databases found for SQL server'); expect(results[0].region).to.equal('eastus'); @@ -86,9 +118,8 @@ describe('enableTransparentDataEncryption', function() { }; const cache = createCache( - servers, - [], - transparentDataEncryptionEnabled + servers, [], transparentDataEncryptionEnabled, + [], [] ); enableTransparentDataEncryption.run(cache, {}, callback); @@ -96,17 +127,16 @@ describe('enableTransparentDataEncryption', function() { it('should give passing result if SQL Database transparent data encryption is enabled', function(done) { const callback = (err, results) => { - expect(results.length).to.equal(1); + expect(results.length).to.equal(2); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Transparent data encryption is enabled for SQL Database'); + expect(results[0].message).to.include('SQL Database: Transparent data encryption is enabled'); expect(results[0].region).to.equal('eastus'); done(); }; const cache = createCache( - servers, - databases, - transparentDataEncryptionEnabled + servers, databases, transparentDataEncryptionEnabled, + [], [] ); enableTransparentDataEncryption.run(cache, {}, callback); @@ -114,22 +144,73 @@ describe('enableTransparentDataEncryption', function() { it('should give failing result if SQL Database transparent data encryption is disabled', function(done) { const callback = (err, results) => { - expect(results.length).to.equal(1); + expect(results.length).to.equal(2); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Transparent data encryption is not enabled for SQL Database'); + expect(results[0].message).to.include('SQL Database: Transparent data encryption is not enabled'); expect(results[0].region).to.equal('eastus'); done(); }; const cache = createCache( - servers, - databases, + servers, databases, [ { "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/databases/test-database/transparentDataEncryption/1", "state": "Disabled" } - ] + ], + [], [] + ); + + enableTransparentDataEncryption.run(cache, {}, callback); + }); + + it('should give passing result if no managed instances found', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('No managed instances found'); + expect(results[1].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], [], [], + [], [] + ); + + enableTransparentDataEncryption.run(cache, {}, callback); + }); + + it('should give passing result if no databases found for managed instance', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('No databases found for managed instance'); + expect(results[1].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], [], [], + managedInstances, [] + ); + + enableTransparentDataEncryption.run(cache, {}, callback); + }); + + it('should give passing result for managed instance database (TDE always enabled)', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('Managed Instance Database: Transparent data encryption is enabled'); + expect(results[1].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], [], [], + managedInstances, managedDatabases ); enableTransparentDataEncryption.run(cache, {}, callback); @@ -137,7 +218,7 @@ describe('enableTransparentDataEncryption', function() { it('should give unknown result if unable to query for SQL servers', function(done) { const callback = (err, results) => { - expect(results.length).to.equal(1); + expect(results.length).to.equal(2); expect(results[0].status).to.equal(3); expect(results[0].message).to.include('Unable to query for SQL servers'); expect(results[0].region).to.equal('eastus'); @@ -145,9 +226,8 @@ describe('enableTransparentDataEncryption', function() { }; const cache = createCache( - [], - databases, - transparentDataEncryptionEnabled, + [], [], [], + [], [], { message: 'unable to query servers' } ); @@ -156,7 +236,7 @@ describe('enableTransparentDataEncryption', function() { it('should give unknown result if unable to query for SQL server databases', function(done) { const callback = (err, results) => { - expect(results.length).to.equal(1); + expect(results.length).to.equal(2); expect(results[0].status).to.equal(3); expect(results[0].message).to.include('Unable to query for SQL server databases'); expect(results[0].region).to.equal('eastus'); @@ -164,32 +244,47 @@ describe('enableTransparentDataEncryption', function() { }; const cache = createCache( - servers, - [], - transparentDataEncryptionEnabled, - null, - { message: 'unable to query databases' } + servers, [], [], + [], [], + null, { message: 'unable to query databases' } ); enableTransparentDataEncryption.run(cache, {}, callback); }); - it('should give unknown result if unable to query for SQL Database transparent data encryption', function(done) { + it('should give unknown result if unable to query for managed instances', 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 transparent data encryption for SQL Database'); - expect(results[0].region).to.equal('eastus'); + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(3); + expect(results[1].message).to.include('Unable to query for managed instances'); + expect(results[1].region).to.equal('eastus'); + done(); + }; + + const cache = createCache( + [], [], [], + [], [], + null, null, null, + { message: 'unable to query managed instances' } + ); + + enableTransparentDataEncryption.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for managed instance databases', function(done) { + const callback = (err, results) => { + expect(results.length).to.equal(2); + expect(results[1].status).to.equal(3); + expect(results[1].message).to.include('Unable to query for managed instance databases'); + expect(results[1].region).to.equal('eastus'); done(); }; const cache = createCache( - servers, - databases, - [], - null, - null, - { message: 'unable to query transparent data encryption' } + [], [], [], + managedInstances, [], + null, null, null, null, + { message: 'unable to query managed databases' } ); enableTransparentDataEncryption.run(cache, {}, callback); diff --git a/plugins/azure/sqlserver/azureADAdminEnabled.js b/plugins/azure/sqlserver/azureADAdminEnabled.js index 0d9282b956..9425656ab1 100644 --- a/plugins/azure/sqlserver/azureADAdminEnabled.js +++ b/plugins/azure/sqlserver/azureADAdminEnabled.js @@ -2,13 +2,13 @@ var async = require('async'); var helpers = require('../../../helpers/azure'); module.exports = { - title: 'Azure Active Directory Admin Enabled', + title: 'Azure Entra ID Admin Enabled', category: 'SQL Server', domain: 'Databases', severity: 'Medium', - description: 'Ensures that Active Directory admin is enabled on all SQL servers.', - more_info: 'Enabling Active Directory admin allows users to manage account admins in a central location, allowing key rotation and permission management to be managed in one location for all servers and databases.', - recommended_action: 'Ensure Azure Active Directory admin is enabled on all SQL servers.', + description: 'Ensures that Entra ID admin is enabled on all SQL servers.', + more_info: 'Enabling Entra ID admin allows users to manage account admins in a central location, allowing key rotation and permission management to be managed in one location for all servers and databases.', + recommended_action: 'Ensure Azure Entra ID admin is enabled on all SQL servers.', link: 'https://learn.microsoft.com/en-us/azure/sql-database/sql-database-aad-authentication-configure', apis: ['servers:listSql', 'serverAzureADAdministrators:listByServer'], realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete','microsoftsql:servers:administrators:write', 'microsoftsql:servers:administrators:delete'], @@ -42,25 +42,25 @@ module.exports = { if (!serverAzureADAdministrators || serverAzureADAdministrators.err || !serverAzureADAdministrators.data) { helpers.addResult(results, 3, - 'Unable to query for Active Directory admins: ' + helpers.addError(serverAzureADAdministrators), location, server.id); + 'Unable to query for Entra ID admins: ' + helpers.addError(serverAzureADAdministrators), location, server.id); } else { if (!serverAzureADAdministrators.data.length) { - helpers.addResult(results, 2, 'Active Directory admin is not enabled on the server', location, server.id); + helpers.addResult(results, 2, 'Entra ID admin is not enabled on the server', location, server.id); } else { - var adAdminEnabled = false; + var entraIdAdminEnabled = false; serverAzureADAdministrators.data.forEach(serverAzureADAdministrator => { if (serverAzureADAdministrator.name && serverAzureADAdministrator.name.toLowerCase() === 'activedirectory') { - adAdminEnabled = true; + entraIdAdminEnabled = true; } }); - if (adAdminEnabled) { + if (entraIdAdminEnabled) { helpers.addResult(results, 0, - 'Active Directory admin is enabled on the SQL server', location, server.id); + 'Entra ID admin is enabled on the SQL server', location, server.id); } else { helpers.addResult(results, 2, - 'Active Directory admin is not enabled on the SQL server', location, server.id); + 'Entra ID admin is not enabled on the SQL server', location, server.id); } } } diff --git a/plugins/azure/sqlserver/azureADAdminEnabled.spec.js b/plugins/azure/sqlserver/azureADAdminEnabled.spec.js index 077b79d2ef..f18770591c 100644 --- a/plugins/azure/sqlserver/azureADAdminEnabled.spec.js +++ b/plugins/azure/sqlserver/azureADAdminEnabled.spec.js @@ -44,7 +44,7 @@ describe('azureADAdminEnabled', function() { const callback = (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Active Directory admin is not enabled on the server'); + expect(results[0].message).to.include('Entra ID admin is not enabled on the server'); expect(results[0].region).to.equal('eastus'); done() }; @@ -73,7 +73,7 @@ describe('azureADAdminEnabled', function() { const callback = (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Active Directory admin is enabled on the SQL server'); + expect(results[0].message).to.include('Entra ID admin is enabled on the SQL server'); expect(results[0].region).to.equal('eastus'); done() }; diff --git a/plugins/azure/sqlserver/sqlServerManagedIdentity.js b/plugins/azure/sqlserver/sqlServerManagedIdentity.js index 56683d330a..0d5e6f55dc 100644 --- a/plugins/azure/sqlserver/sqlServerManagedIdentity.js +++ b/plugins/azure/sqlserver/sqlServerManagedIdentity.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Databases', severity: 'Medium', description: 'Ensure that Azure SQL servers 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: 'Enable system or user-assigned managed identities for sql servers.', link: 'https://learn.microsoft.com/en-us/azure/azure-sql/database/authentication-azure-ad-user-assigned-managed-identity?view=azuresql', apis: ['servers:listSql'], diff --git a/plugins/azure/sqlserver/sqlServerTlsVersion.js b/plugins/azure/sqlserver/sqlServerTlsVersion.js index 1b51bd8220..0628a61867 100644 --- a/plugins/azure/sqlserver/sqlServerTlsVersion.js +++ b/plugins/azure/sqlserver/sqlServerTlsVersion.js @@ -11,14 +11,6 @@ module.exports = { recommended_action: 'Modify SQL server firewall and virtual network settings to set desired minimum TLS version.', link: 'https://learn.microsoft.com/en-us/azure/azure-sql/database/connectivity-settings#minimal-tls-version', apis: ['servers:listSql'], - settings: { - sql_server_min_tls_version: { - name: 'SQL Server Minimum TLS Version', - description: 'Minimum desired TLS version for Microsoft Azure SQL servers', - regex: '^(1.0|1.1|1.2)$', - default: '1.2' - } - }, remediation_min_version: '202104012200', remediation_description: 'TLS version 1.2 will be set for the affected SQL server', apis_remediate: ['servers:listSql'], @@ -31,11 +23,9 @@ module.exports = { var source = {}; var locations = helpers.locations(settings.govcloud); - var config = { - sql_server_min_tls_version: settings.sql_server_min_tls_version || this.settings.sql_server_min_tls_version.default - }; + var sql_server_min_tls_version = '1.2'; - var desiredVersion = parseFloat(config.sql_server_min_tls_version); + var desiredVersion = parseFloat(sql_server_min_tls_version); async.each(locations.servers, function(location, rcb) { var servers = helpers.addSource(cache, source, @@ -60,11 +50,11 @@ module.exports = { if (server.minimalTlsVersion) { if (parseFloat(server.minimalTlsVersion) >= desiredVersion) { helpers.addResult(results, 0, - `SQL server is using TLS version ${server.minimalTlsVersion} which is equal to or higher than desired TLS version ${config.sql_server_min_tls_version}`, + `SQL server is using TLS version ${server.minimalTlsVersion} which is equal to or higher than desired TLS version ${sql_server_min_tls_version}`, location, server.id); } else { helpers.addResult(results, 2, - `SQL server is using TLS version ${server.minimalTlsVersion} which is less than desired TLS version ${config.sql_server_min_tls_version}`, + `SQL server is using TLS version ${server.minimalTlsVersion} which is less than desired TLS version ${sql_server_min_tls_version}`, location, server.id); } } else { diff --git a/plugins/azure/sqlserver/sqlServerTlsVersion.spec.js b/plugins/azure/sqlserver/sqlServerTlsVersion.spec.js index 75f76df122..43ba9b3374 100644 --- a/plugins/azure/sqlserver/sqlServerTlsVersion.spec.js +++ b/plugins/azure/sqlserver/sqlServerTlsVersion.spec.js @@ -30,6 +30,21 @@ const servers = [ "fullyQualifiedDomainName": "test-server.database.windows.net", "privateEndpointConnections": [], "publicNetworkAccess": "Enabled" + }, + { + "kind": "v12.0", + "location": "eastus", + "tags": {}, + "id": "/subscriptions/123/resourceGroups/akhtar-rg/providers/Microsoft.Sql/servers/test-server", + "name": "test-server", + "type": "Microsoft.Sql/servers", + "administratorLogin": "aqua", + "version": "12.0", + "state": "Ready", + "fullyQualifiedDomainName": "test-server.database.windows.net", + "privateEndpointConnections": [], + "minimalTlsVersion": "1.2", + "publicNetworkAccess": "Enabled" } ]; @@ -106,10 +121,10 @@ describe('sqlServerTlsVersion', function() { }; const cache = createCache( - [servers[0]] + [servers[2]] ); - sqlServerTlsVersion.run(cache, { sql_server_min_tls_version: '1.0' }, callback); + sqlServerTlsVersion.run(cache, { sql_server_min_tls_version: '1.2' }, callback); }); it('should give unknown result if unable to query for SQL servers', function(done) { diff --git a/plugins/azure/sqlserver/tdeProtectorEncrypted.js b/plugins/azure/sqlserver/tdeProtectorEncrypted.js index 1c1f6c324d..e230aa181a 100644 --- a/plugins/azure/sqlserver/tdeProtectorEncrypted.js +++ b/plugins/azure/sqlserver/tdeProtectorEncrypted.js @@ -8,82 +8,127 @@ module.exports = { severity: 'Medium', description: 'Ensures SQL Server TDE protector is encrypted with BYOK (Bring Your Own Key)', more_info: 'Enabling BYOK in the TDE protector allows for greater control and transparency, as well as increasing security by having full control of the encryption keys.', - recommended_action: 'Ensure that a BYOK key is set for the Transparent Data Encryption of each SQL Server.', + recommended_action: 'Ensure that a BYOK key is set for the Transparent Data Encryption of each SQL Server or Managed Instance.', link: 'https://learn.microsoft.com/en-us/azure/sql-database/transparent-data-encryption-byok-azure-sql', - apis: ['servers:listSql', 'encryptionProtectors:listByServer'], + apis: ['servers:listSql', 'encryptionProtectors:listByServer', 'managedInstances:list', 'managedInstanceEncryptionProtectors:listByInstance'], settings: { sql_tde_protector_encryption_key: { name: 'SQL Server TDE Protector Encryption Key Type', - description: 'Desired encryption key for SQL Server transparent data encryption; default=service-managed key, cmk=customer-managed key', + description: 'Desired encryption key for SQL Server and Managed Instance transparent data encryption; default=service-managed key, cmk=customer-managed key', regex: '(default|byok)', default: 'byok' } }, - realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsodtsql:servers:encryptionprotector:write'], + realtime_triggers: ['microsoftsql:servers:write', 'microsoftsql:servers:delete', 'microsoftsql:servers:encryptionprotector:write'], run: function(cache, settings, callback) { const results = []; const source = {}; const locations = helpers.locations(settings.govcloud); - + var config = { sql_tde_protector_encryption_key: settings.sql_tde_protector_encryption_key || this.settings.sql_tde_protector_encryption_key.default }; - - async.each(locations.servers, function(location, rcb) { - - var servers = helpers.addSource(cache, source, - ['servers', 'listSql', location]); - - if (!servers) return rcb(); - - if (servers.err || !servers.data) { - helpers.addResult(results, 3, - 'Unable to query for SQL servers: ' + helpers.addError(servers), location); - return rcb(); - } - - if (!servers.data.length) { - helpers.addResult(results, 0, 'No SQL servers found', location); - return rcb(); - } - - servers.data.forEach(function(server) { - const encryptionProtectors = helpers.addSource(cache, source, - ['encryptionProtectors', 'listByServer', location, server.id]); - - if (!encryptionProtectors || encryptionProtectors.err || !encryptionProtectors.data) { - helpers.addResult(results, 3, - 'Unable to query for SQL Server Encryption Protectors: ' + helpers.addError(encryptionProtectors), location, server.id); + + function checkEncryptionProtection(encryptionProtector, location, config, serviceType) { + if (config.sql_tde_protector_encryption_key == 'byok') { + if ((encryptionProtector.kind && + encryptionProtector.kind.toLowerCase() != 'azurekeyvault') || + (encryptionProtector.serverKeyType && + encryptionProtector.serverKeyType.toLowerCase() != 'azurekeyvault') || + !encryptionProtector.uri) { + helpers.addResult(results, 2, + `${serviceType} TDE protector is not encrypted with BYOK`, location, encryptionProtector.id); } else { - if (!encryptionProtectors.data.length) { - helpers.addResult(results, 0, 'No SQL Server Encryption Protectors found for server', location, server.id); - } else { - encryptionProtectors.data.forEach(encryptionProtector => { - if (config.sql_tde_protector_encryption_key == 'byok') { - if ((encryptionProtector.kind && - encryptionProtector.kind.toLowerCase() != 'azurekeyvault') || - (encryptionProtector.serverKeyType && - encryptionProtector.serverKeyType.toLowerCase() != 'azurekeyvault') || - !encryptionProtector.uri) { - helpers.addResult(results, 2, - 'SQL Server TDE protector is not encrypted with BYOK', location, encryptionProtector.id); - } else { - helpers.addResult(results, 0, - 'SQL Server TDE protector is encrypted with BYOK', location, encryptionProtector.id); - } - } else { - helpers.addResult(results, 0, - 'SQL Server TDE protector is encrypted with service-managed key', location, encryptionProtector.id); - } - }); + helpers.addResult(results, 0, + `${serviceType} TDE protector is encrypted with BYOK`, location, encryptionProtector.id); + } + } else { + if (encryptionProtector.kind || encryptionProtector.serverKeyType) { + helpers.addResult(results, 0, + `${serviceType} TDE protector is encrypted with service-managed key`, location, encryptionProtector.id); + } + } + } + + async.each(locations.servers, function(location, rcb) { + async.parallel([ + // Check SQL Servers + function(cb) { + const servers = helpers.addSource(cache, source, + ['servers', 'listSql', location]); + + if (!servers) return cb(); + + if (servers.err || !servers.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL servers: ' + helpers.addError(servers), location); + return cb(); + } + + if (!servers.data.length) { + helpers.addResult(results, 0, 'No SQL servers found', location); + return cb(); + } + + servers.data.forEach(server => { + const encryptionProtectors = helpers.addSource(cache, source, + ['encryptionProtectors', 'listByServer', location, server.id]); + + if (!encryptionProtectors || encryptionProtectors.err || !encryptionProtectors.data) { + helpers.addResult(results, 3, + 'Unable to query for SQL Server Encryption Protectors: ' + helpers.addError(encryptionProtectors), location, server.id); + } else if (!encryptionProtectors.data.length) { + helpers.addResult(results, 0, 'No SQL Server Encryption Protectors found', location, server.id); + } else { + encryptionProtectors.data.forEach(protector => { + checkEncryptionProtection(protector, location, config, 'SQL Server'); + }); + } + }); + + cb(); + }, + // Check Managed Instances + function(cb) { + const managedInstances = helpers.addSource(cache, source, + ['managedInstances', 'list', location]); + + if (!managedInstances) return cb(); + + if (managedInstances.err || !managedInstances.data) { + helpers.addResult(results, 3, + 'Unable to query for managed instances: ' + helpers.addError(managedInstances), location); + return cb(); } + + if (!managedInstances.data.length) { + helpers.addResult(results, 0, 'No managed instances found', location); + return cb(); + } + + managedInstances.data.forEach(instance => { + const managedInstanceEncryptionProtectors = helpers.addSource(cache, source, + ['managedInstanceEncryptionProtectors', 'listByInstance', location, instance.id]); + + if (!managedInstanceEncryptionProtectors || managedInstanceEncryptionProtectors.err || !managedInstanceEncryptionProtectors.data) { + helpers.addResult(results, 3, + 'Unable to query for Managed Instance Encryption Protectors: ' + helpers.addError(managedInstanceEncryptionProtectors), location, instance.id); + } else if (!managedInstanceEncryptionProtectors.data.length) { + helpers.addResult(results, 0, 'No Managed Instance Encryption Protectors found', location, instance.id); + } else { + managedInstanceEncryptionProtectors.data.forEach(protector => { + checkEncryptionProtection(protector, location, config, 'Managed Instance'); + }); + } + }); + + cb(); } + ], function() { + rcb(); }); - - rcb(); }, function() { - // Global checking goes here callback(null, results, source); }); } diff --git a/plugins/azure/sqlserver/tdeProtectorEncrypted.spec.js b/plugins/azure/sqlserver/tdeProtectorEncrypted.spec.js index 98f983dcde..b7e8066b71 100644 --- a/plugins/azure/sqlserver/tdeProtectorEncrypted.spec.js +++ b/plugins/azure/sqlserver/tdeProtectorEncrypted.spec.js @@ -1,56 +1,43 @@ -var expect = require('chai').expect; -var tdeProtectorEncrypted = require('./tdeProtectorEncrypted'); +const expect = require('chai').expect; +const tdeProtectorEncrypted = require('./tdeProtectorEncrypted'); const servers = [ { - "identity": { - "principalId": "86c16ef5-51e9-4ecb-aeda-5844b8e8eca0", - "type": "SystemAssigned", - "tenantId": "2d4f0836-5935-47f5-954c-14e713119ac2" - }, - "kind": "v12.0", - "location": "eastus", - "tags": {}, - "id": "/subscriptions/1234/resourceGroups/akhtar-rg/providers/Microsoft.Sql/servers/akhtar-server", - "name": "akhtar-server", - "type": "Microsoft.Sql/servers", - "administratorLogin": "akhtar", - "version": "12.0", - "state": "Ready", - "fullyQualifiedDomainName": "akhtar-server.database.windows.net", - "privateEndpointConnections": [], - "minimalTlsVersion": "1.2", - "publicNetworkAccess": "Enabled" - } + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server", + "name": "test-server", + "type": "Microsoft.Sql/servers" + } ]; -const encryptionProtectors = [ - { - "kind": "azurekeyvault", - "id": "/subscriptions/1234/resourceGroups/akhtar-rg/providers/Microsoft.Sql/servers/akhtar-server/encryptionProtector/current", - "name": "current", - "type": "Microsoft.Sql/servers/encryptionProtector", - "serverKeyName": "sadeed-vault_sadeed-key_b5e783becb4a4e789dcd1239441d7567", - "serverKeyType": "AzureKeyVault", - "uri": "https://sadeed-vault.vault.azure.net/keys/sadeed-key/b5e783becb4a4e789dcd1239441d7567" - }, +const managedInstances = [ { - "kind": "servicemanaged", - "id": "/subscriptions/1234/resourceGroups/akhtar-rg/providers/Microsoft.Sql/servers/akhtar-server/encryptionProtector/current", - "name": "current", - "type": "Microsoft.Sql/servers/encryptionProtector", - "serverKeyName": "ServiceManaged", - "serverKeyType": "ServiceManaged" + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/managedInstances/test-instance", + "name": "test-instance", + "type": "Microsoft.Sql/managedInstances" } ]; -const createCache = (servers, encrypted, serversErr, encryptedErr) => { +const byokEncryptionProtector = { + "kind": "azurekeyvault", + "serverKeyType": "AzureKeyVault", + "uri": "https://test-vault.vault.azure.net/keys/test-key/123", + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/encryptionProtector/current" +}; + +const serviceEncryptionProtector = { + "kind": "servicemanaged", + "serverKeyType": "ServiceManaged", + "id": "/subscriptions/123/resourceGroups/test-rg/providers/Microsoft.Sql/servers/test-server/encryptionProtector/current" +}; + +const createCache = (servers, serverEncryption, managedInstances, managedInstanceEncryption) => { const serverId = (servers && servers.length) ? servers[0].id : null; + const managedInstanceId = (managedInstances && managedInstances.length) ? managedInstances[0].id : null; return { servers: { listSql: { 'eastus': { - err: serversErr, + err: null, data: servers } } @@ -59,119 +46,126 @@ const createCache = (servers, encrypted, serversErr, encryptedErr) => { listByServer: { 'eastus': { [serverId]: { - err: encryptedErr, - data: encrypted + err: null, + data: serverEncryption + } + } + } + }, + managedInstances: { + list: { + 'eastus': { + err: null, + data: managedInstances + } + } + }, + managedInstanceEncryptionProtectors: { + listByInstance: { + 'eastus': { + [managedInstanceId]: { + err: null, + data: managedInstanceEncryption } } } } - } + }; }; describe('tdeProtectorEncrypted', function() { describe('run', function() { - it('should give passing result if no SQL servers found', function(done) { + it('should give passing result if no SQL servers or managed instances found', function(done) { const callback = (err, results) => { - expect(results.length).to.equal(1); + expect(results.length).to.equal(2); expect(results[0].status).to.equal(0); expect(results[0].message).to.include('No SQL servers found'); - expect(results[0].region).to.equal('eastus'); - done() + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('No managed instances found'); + done(); }; - const cache = createCache( - [] - ); - + const cache = createCache([], null, [], null); tdeProtectorEncrypted.run(cache, {}, callback); }); - it('should give passing result if No SQL Server Encryption Protectors found for server', function(done) { + it('should give passing result if no encryption protectors found', function(done) { const callback = (err, results) => { - expect(results.length).to.equal(1); + expect(results.length).to.equal(2); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('No SQL Server Encryption Protectors found for server'); - expect(results[0].region).to.equal('eastus'); - done() + expect(results[0].message).to.include('No SQL Server Encryption Protectors found'); + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('No Managed Instance Encryption Protectors found'); + done(); }; - const cache = createCache( - servers, - [] - ); - + const cache = createCache(servers, [], managedInstances, []); tdeProtectorEncrypted.run(cache, {}, callback); }); - it('should give failing result if SQL Server TDE protector is not encrypted with BYOK', function(done) { + it('should give failing result if TDE protector is not encrypted with BYOK', function(done) { const callback = (err, results) => { - expect(results.length).to.equal(1); + expect(results.length).to.equal(2); expect(results[0].status).to.equal(2); expect(results[0].message).to.include('SQL Server TDE protector is not encrypted with BYOK'); - expect(results[0].region).to.equal('eastus'); - done() + expect(results[1].status).to.equal(2); + expect(results[1].message).to.include('Managed Instance TDE protector is not encrypted with BYOK'); + done(); }; const cache = createCache( - servers, - [encryptionProtectors[1]] + servers, [serviceEncryptionProtector], + managedInstances, [serviceEncryptionProtector] ); - tdeProtectorEncrypted.run(cache, {}, callback); }); - it('should give passing result if SQL Server TDE protector is encrypted with BYOK', function(done) { + it('should give passing result if TDE protector is encrypted with BYOK', function(done) { const callback = (err, results) => { - expect(results.length).to.equal(1); + expect(results.length).to.equal(2); expect(results[0].status).to.equal(0); expect(results[0].message).to.include('SQL Server TDE protector is encrypted with BYOK'); - expect(results[0].region).to.equal('eastus'); - done() + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('Managed Instance TDE protector is encrypted with BYOK'); + done(); }; const cache = createCache( - servers, - [encryptionProtectors[0]] + servers, [byokEncryptionProtector], + managedInstances, [byokEncryptionProtector] ); - tdeProtectorEncrypted.run(cache, {}, callback); }); - it('should give unknown result if Unable to query for SQL servers', function(done) { + it('should give passing result if TDE protector is encrypted with service-managed key when that is allowed', 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 SQL servers'); - expect(results[0].region).to.equal('eastus'); - done() + expect(results.length).to.equal(2); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('SQL Server TDE protector is encrypted with service-managed key'); + expect(results[1].status).to.equal(0); + expect(results[1].message).to.include('Managed Instance TDE protector is encrypted with service-managed key'); + done(); }; const cache = createCache( - servers, - [], - { message: 'unable to query servers'} + servers, [serviceEncryptionProtector], + managedInstances, [serviceEncryptionProtector] ); - - tdeProtectorEncrypted.run(cache, {}, callback); + tdeProtectorEncrypted.run(cache, { sql_tde_protector_encryption_key: 'default' }, callback); }); - it('should give unknown result if Unable to query for SQL Server Encryption Protectors', function(done) { + it('should give unknown result if unable to query SQL servers or managed instances', function(done) { const callback = (err, results) => { - expect(results.length).to.equal(1); + expect(results.length).to.equal(2); expect(results[0].status).to.equal(3); - expect(results[0].message).to.include('Unable to query for SQL Server Encryption Protectors'); - expect(results[0].region).to.equal('eastus'); - done() + expect(results[0].message).to.include('Unable to query for SQL servers'); + expect(results[1].status).to.equal(3); + expect(results[1].message).to.include('Unable to query for managed instances'); + done(); }; - const cache = createCache( - servers, - [], - null, - { message: 'Unable to query for Vulnerability Assessments setting'} - ); - + const cache = createCache(null, null, null, null); tdeProtectorEncrypted.run(cache, {}, callback); }); - }) + }); }); \ No newline at end of file diff --git a/plugins/azure/storageaccounts/storageAccountPrivateEndpoint.js b/plugins/azure/storageaccounts/storageAccountPrivateEndpoint.js index b5baa5df92..1150e02c54 100644 --- a/plugins/azure/storageaccounts/storageAccountPrivateEndpoint.js +++ b/plugins/azure/storageaccounts/storageAccountPrivateEndpoint.js @@ -6,17 +6,28 @@ module.exports = { category: 'Storage Accounts', domain: 'Storage', severity: 'Medium', - description: 'Ensure that Azure Storage accounts are accessible only through private endpoints.', - more_info: 'Azure Private Endpoint is a network interface that connects you privately and securely to a service powered by Azure Private Link. Private Endpoint uses a private IP address from your VNet, effectively bringing the service such as Azure Storage Accounts into your VNet.', - recommended_action: 'Modify storage accounts and configure private endpoints.', + description: 'Ensure that Azure Storage accounts are accessible only through private endpoints or have restricted public access.', + more_info: 'Azure Private Endpoint is a network interface that connects you privately and securely to a service powered by Azure Private Link. Private Endpoint uses a private IP address from your VNet, effectively bringing the service such as Azure Storage Accounts into your VNet. If private endpoints are not configured, ensure that public access is restricted to specific IP addresses or virtual networks.', + recommended_action: 'Modify storage accounts and configure private endpoints or restrict public access to specific networks.', link: 'https://learn.microsoft.com/en-us/azure/storage/common/storage-private-endpoints', apis: ['storageAccounts:list'], realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete', 'microsoftnetwork:privateendpoints:write', 'microsoftstorage:storageaccounts:privateendpointconnections:write'], + settings: { + check_selected_networks: { + name: 'Evaluate Selected Networks', + description: 'Checks if specific IP addresses or virtual networks are set to restrict Storage Account access when private endpoints are not configured.', + regex: '^(true|false)$', + default: false, + } + }, run: function(cache, settings, callback) { var results = []; var source = {}; var locations = helpers.locations(settings.govcloud); + let config = { + check_selected_networks: settings.check_selected_networks || this.settings.check_selected_networks.default + }; async.each(locations.storageAccounts, function(location, rcb) { var storageAccount = helpers.addSource(cache, source, @@ -41,7 +52,38 @@ module.exports = { if (account.privateEndpointConnections && account.privateEndpointConnections.length){ helpers.addResult(results, 0, 'Private endpoints are configured for the storage account', location, account.id); } else { - helpers.addResult(results, 2, 'Private endpoints are not configured for the storage account', location, account.id); + // Check public network access when private endpoints are not configured + let isPublicAccessEnabled = (account.publicNetworkAccess && account.publicNetworkAccess.toLowerCase() === 'enabled') || + (!account.publicNetworkAccess && account.networkAcls && account.networkAcls.defaultAction && account.networkAcls.defaultAction.toLowerCase() === 'allow'); + + if (isPublicAccessEnabled) { + if (config.check_selected_networks) { + let hasNetworkRestrictions = false; + + if (account.networkAcls) { + // Check if default action is deny (meaning public access is restricted) + if (account.networkAcls.defaultAction && account.networkAcls.defaultAction.toLowerCase() === 'deny') { + hasNetworkRestrictions = true; + } + + // Check if there are IP rules or virtual network rules configured + if ((account.networkAcls.ipRules && account.networkAcls.ipRules.length > 0) || + (account.networkAcls.virtualNetworkRules && account.networkAcls.virtualNetworkRules.length > 0)) { + hasNetworkRestrictions = true; + } + } + + if (hasNetworkRestrictions) { + helpers.addResult(results, 0, 'Storage account is not publicly accessible', location, account.id); + } else { + helpers.addResult(results, 2, 'Storage account is publicly accessible', location, account.id); + } + } else { + helpers.addResult(results, 2, 'Storage account is publicly accessible', location, account.id); + } + } else { + helpers.addResult(results, 0, 'Storage account is not publicly accessible', location, account.id); + } } } diff --git a/plugins/azure/storageaccounts/storageAccountPrivateEndpoint.spec.js b/plugins/azure/storageaccounts/storageAccountPrivateEndpoint.spec.js index e790acc634..9fd437ddb9 100644 --- a/plugins/azure/storageaccounts/storageAccountPrivateEndpoint.spec.js +++ b/plugins/azure/storageaccounts/storageAccountPrivateEndpoint.spec.js @@ -3,25 +3,78 @@ var storageAccountPrivateEndpoint = require('./storageAccountPrivateEndpoint'); const storageAccounts = [ { - 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc1', 'location': 'eastus', - 'name': 'acc', + 'name': 'acc1', 'tags': { 'key': 'value' }, "privateEndpointConnections": [ - { - "id": "/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc/privateEndpointConnections/test.3d321801-7cb1-4586-afa7-deee7ab88744", + "id": "/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc1/privateEndpointConnections/test.3d321801-7cb1-4586-afa7-deee7ab88744", "name": "test.3d321801-7cb1-4586-afa7-deee7ab88744", "type": "Microsoft.Storage/storageAccounts/privateEndpointConnections", } ], + "publicNetworkAccess": "Enabled" + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc2', + 'location': 'eastus', + 'name': 'acc2', + 'tags': {}, + "privateEndpointConnections": [], + "publicNetworkAccess": "Disabled" + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc3', + 'location': 'eastus', + 'name': 'acc3', + 'tags': {}, + "privateEndpointConnections": [], + "publicNetworkAccess": "Enabled" + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc4', + 'location': 'eastus', + 'name': 'acc4', + 'tags': {}, + "privateEndpointConnections": [], + "publicNetworkAccess": "Enabled", + "networkAcls": { + "defaultAction": "Deny", + "ipRules": [ + { + "value": "192.168.1.0/24", + "action": "Allow" + } + ], + "virtualNetworkRules": [] + } }, { - 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc5', 'location': 'eastus', - 'name': 'acc', + 'name': 'acc5', 'tags': {}, - "privateEndpointConnections": [] + "privateEndpointConnections": [], + "publicNetworkAccess": "Enabled", + "networkAcls": { + "defaultAction": "Allow", + "ipRules": [], + "virtualNetworkRules": [] + } + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc6', + 'location': 'eastus', + 'name': 'acc6', + 'tags': {}, + "privateEndpointConnections": [], + // publicNetworkAccess property missing + "networkAcls": { + "defaultAction": "Allow", + "ipRules": [], + "virtualNetworkRules": [] + } } ]; @@ -82,12 +135,70 @@ describe('storageAccountPrivateEndpoint', function() { }); }); - it('should give failing result if no private endpoint', function(done) { + it('should give passing result if public network access is disabled', function(done) { const cache = createCache([storageAccounts[1]]); + storageAccountPrivateEndpoint.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage account is not publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if public network access is enabled without network restrictions', function(done) { + const cache = createCache([storageAccounts[2]]); + storageAccountPrivateEndpoint.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Storage account is publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if public network access is enabled with network restrictions when check_selected_networks is true', function(done) { + const cache = createCache([storageAccounts[3]]); + const settings = { check_selected_networks: true }; + storageAccountPrivateEndpoint.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage account is not publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if public network access is enabled without sufficient network restrictions when check_selected_networks is true', function(done) { + const cache = createCache([storageAccounts[4]]); + const settings = { check_selected_networks: true }; + storageAccountPrivateEndpoint.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Storage account is publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if public network access is enabled regardless of network restrictions when check_selected_networks is false', function(done) { + const cache = createCache([storageAccounts[3]]); + const settings = { check_selected_networks: false }; + storageAccountPrivateEndpoint.run(cache, settings, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Storage account is publicly accessible'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if publicNetworkAccess is missing but networkAcls defaultAction is Allow', function(done) { + const cache = createCache([storageAccounts[5]]); storageAccountPrivateEndpoint.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Private endpoints are not configured for the storage account'); + expect(results[0].message).to.include('Storage account is publicly accessible'); expect(results[0].region).to.equal('eastus'); done(); }); diff --git a/plugins/azure/storageaccounts/storageAccountPublicNetworkAccess.js b/plugins/azure/storageaccounts/storageAccountPublicNetworkAccess.js new file mode 100644 index 0000000000..83dd52a9e4 --- /dev/null +++ b/plugins/azure/storageaccounts/storageAccountPublicNetworkAccess.js @@ -0,0 +1,54 @@ +var async = require('async'); +var helpers = require('../../../helpers/azure/'); + +module.exports = { + title: 'Storage Account Public Network Access', + category: 'Storage Accounts', + domain: 'Storage', + severity: 'Medium', + description: 'Ensures that Public Network Access is disabled for storage accounts.', + more_info: 'Disabling public network access for Azure storage accounts enhances security by blocking anonymous access to data in containers and blobs. This restriction ensures that only trusted network sources can access the storage, reducing the risk of unauthorized access and data exposure.', + recommended_action: 'Modify storage accounts and disable Public Network Access.', + link: 'https://learn.microsoft.com/en-us/azure/storage/common/storage-network-security', + apis: ['storageAccounts:list'], + realtime_triggers: ['microsoftstorage:storageaccounts:write', 'microsoftstorage:storageaccounts:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.storageAccounts, function(location, rcb) { + var storageAccount = helpers.addSource(cache, source, + ['storageAccounts', 'list', location]); + + if (!storageAccount) return rcb(); + + if (storageAccount.err || !storageAccount.data) { + helpers.addResult(results, 3, + 'Unable to query for Storage Accounts: ' + helpers.addError(storageAccount), location); + return rcb(); + } + + if (!storageAccount.data.length) { + helpers.addResult(results, 0, 'No storage accounts found', location); + return rcb(); + } + + for (let account of storageAccount.data) { + if (!account.id) continue; + + if (account.publicNetworkAccess && (account.publicNetworkAccess.toLowerCase() == 'disabled' || account.publicNetworkAccess.toLowerCase() == 'securedbyperimeter')){ + helpers.addResult(results, 0, 'Storage account has public network access disabled', location, account.id); + } else { + helpers.addResult(results, 2, 'Storage account does not have public network access disabled', location, account.id); + } + } + + rcb(); + }, function() { + // Global checking goes here + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/storageaccounts/storageAccountPublicNetworkAccess.spec.js b/plugins/azure/storageaccounts/storageAccountPublicNetworkAccess.spec.js new file mode 100644 index 0000000000..454522eda5 --- /dev/null +++ b/plugins/azure/storageaccounts/storageAccountPublicNetworkAccess.spec.js @@ -0,0 +1,107 @@ +var expect = require('chai').expect; +var storageAccountPublicNetworkAccess = require('./storageAccountPublicNetworkAccess'); + +const storageAccounts = [ + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + 'tags': { 'key': 'value' }, + "publicNetworkAccess": "Disabled" + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + 'tags': {}, + "publicNetworkAccess": "Enabled" + }, + { + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Storage/storageAccounts/acc', + 'location': 'eastus', + 'name': 'acc', + 'tags': {}, + "publicNetworkAccess": "SecuredByPerimeter" + } +]; + +const createCache = (storageAccounts) => { + return { + storageAccounts: { + list: { + 'eastus': { + data: storageAccounts + } + } + } + }; +}; + +const createErrorCache = () => { + return { + storageAccounts: { + list: { + 'eastus': {} + } + } + }; +}; + +describe('storageAccountPublicNetworkAccess', function() { + describe('run', function() { + it('should give passing result if no storage accounts', function(done) { + const cache = createCache([]); + storageAccountPublicNetworkAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No storage accounts found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for storage accounts', function(done) { + const cache = createErrorCache(); + storageAccountPublicNetworkAccess.run(cache, {}, (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 Storage Accounts'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Storage account has public network access disabled', function(done) { + const cache = createCache([storageAccounts[0]]); + storageAccountPublicNetworkAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage account has public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Storage account does not have public network access disabled', function(done) { + const cache = createCache([storageAccounts[1]]); + storageAccountPublicNetworkAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Storage account does not have public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Storage account has public network access secured by perimeter', function(done) { + const cache = createCache([storageAccounts[2]]); + storageAccountPublicNetworkAccess.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Storage account has public network access disabled'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/storageaccounts/storageAccountsAADEnabled.js b/plugins/azure/storageaccounts/storageAccountsAADEnabled.js index 8cd2a17186..e22b638f2a 100644 --- a/plugins/azure/storageaccounts/storageAccountsAADEnabled.js +++ b/plugins/azure/storageaccounts/storageAccountsAADEnabled.js @@ -2,12 +2,12 @@ var async = require('async'); var helpers = require('../../../helpers/azure/'); module.exports = { - title: 'Storage Accounts AAD Enabled', + title: 'Storage Accounts Entra ID Enabled', category: 'Storage Accounts', domain: 'Storage', severity: 'Medium', description: 'Ensures that identity-based Directory Service for Azure File Authentication is enabled for all Azure Files', - more_info: 'Enabling identity-based Authentication ensures that only the authorized Active Directory members can access or connect to the file shares, enforcing granular access control.', + more_info: 'Enabling identity-based Authentication ensures that only the authorized Entra ID members can access or connect to the file shares, enforcing granular access control.', recommended_action: 'Ensure that identity-based Directory Service for Azure File Authentication is enabled for all Azure File Shares.', link: 'https://learn.microsoft.com/en-us/azure/storage/files/storage-files-active-directory-overview', apis: ['storageAccounts:list', 'fileShares:list'], @@ -66,7 +66,7 @@ module.exports = { location, storageAccount.id, custom); } else { if (storageAccount.enableAzureFilesAadIntegration) { - helpers.addResult(results, 0, 'Storage Account is configured with AAD Authentication', location, storageAccount.id); + helpers.addResult(results, 0, 'Storage Account is configured with Entra ID Authentication', location, storageAccount.id); } else if (config.storage_account_check_file_share) { var fileShares = helpers.addSource(cache, source, ['fileShares', 'list', location, storageAccount.id]); @@ -76,13 +76,13 @@ module.exports = { 'Unable to query for file shares: ' + helpers.addError(fileShares), location, storageAccount.id); } else { if (!fileShares.data.length) { - helpers.addResult(results, 0, 'Storage Account is not configured with AAD Authentication but no file shares are present', location, storageAccount.id); + helpers.addResult(results, 0, 'Storage Account is not configured with Entra ID Authentication but no file shares are present', location, storageAccount.id); } else { - helpers.addResult(results, 2, 'Storage Account is not configured with AAD Authentication', location, storageAccount.id); + helpers.addResult(results, 2, 'Storage Account is not configured with Entra ID Authentication', location, storageAccount.id); } } } else { - helpers.addResult(results, 2, 'Storage Account is not configured with AAD Authentication', location, storageAccount.id); + helpers.addResult(results, 2, 'Storage Account is not configured with Entra ID Authentication', location, storageAccount.id); } } diff --git a/plugins/azure/storageaccounts/storageAccountsAADEnabled.spec.js b/plugins/azure/storageaccounts/storageAccountsAADEnabled.spec.js index c916b6aab1..72728b5b04 100644 --- a/plugins/azure/storageaccounts/storageAccountsAADEnabled.spec.js +++ b/plugins/azure/storageaccounts/storageAccountsAADEnabled.spec.js @@ -41,11 +41,11 @@ describe('storageAccountsAADEnabled', function() { auth.run(cache, {}, callback); }) - it('should give failing result if storage account is not configured with aad authentication', function(done) { + it('should give failing result if storage account is not configured with Entra ID authentication', 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('Storage Account is not configured with AAD Authentication'); + expect(results[0].message).to.include('Storage Account is not configured with Entra ID Authentication'); expect(results[0].region).to.equal('eastus'); done() }; @@ -114,11 +114,11 @@ describe('storageAccountsAADEnabled', function() { auth.run(cache, {}, callback); }) - it('should give passing result if storage account is not configured with aad authentication but no file shares', function(done) { + it('should give passing result if storage account is not configured with Entra ID authentication but no file shares', 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('Storage Account is not configured with AAD Authentication but no file shares are present'); + expect(results[0].message).to.include('Storage Account is not configured with Entra ID Authentication but no file shares are present'); expect(results[0].region).to.equal('eastus'); done() }; @@ -183,7 +183,7 @@ describe('storageAccountsAADEnabled', function() { const callback = (err, results) => { expect(results.length).to.equal(1) expect(results[0].status).to.equal(0) - expect(results[0].message).to.include('Storage Account is configured with AAD Authentication'); + expect(results[0].message).to.include('Storage Account is configured with Entra ID Authentication'); expect(results[0].region).to.equal('eastus') done() }; diff --git a/plugins/azure/storageaccounts/storageAccountsTlsVersion.js b/plugins/azure/storageaccounts/storageAccountsTlsVersion.js index a0fac3088c..1234d1eca8 100644 --- a/plugins/azure/storageaccounts/storageAccountsTlsVersion.js +++ b/plugins/azure/storageaccounts/storageAccountsTlsVersion.js @@ -12,14 +12,6 @@ module.exports = { recommended_action: 'Modify Storage Account configuration and set desired minimum TLS version', link: 'https://learn.microsoft.com/en-us/azure/storage/common/transport-layer-security-configure-minimum-version', apis: ['storageAccounts:list'], - settings: { - sa_min_tls_version: { - name: 'Storage Account Minimum TLS Version', - description: 'Minimum desired TLS version for Microsoft Azure Storage Accounts', - regex: '^(1.0|1.1|1.2)$', - default: '1.2' - } - }, remediation_min_version: '202112312200', remediation_description: 'TLS version 1.2 will be set for the affected Storage Accounts', apis_remediate: ['storageAccounts:list'], @@ -32,11 +24,9 @@ module.exports = { var source = {}; var locations = helpers.locations(settings.govcloud); - var config = { - sa_min_tls_version: settings.sa_min_tls_version || this.settings.sa_min_tls_version.default - }; + var sa_min_tls_version = '1.2'; - var desiredVersion = parseFloat(config.sa_min_tls_version); + var desiredVersion = parseFloat(sa_min_tls_version); async.each(locations.storageAccounts, function(location, rcb) { var storageAccounts = helpers.addSource(cache, source, @@ -58,17 +48,17 @@ module.exports = { storageAccounts.data.forEach(function(storageAccount) { if (!storageAccount.id) return; - let tlsVersion = storageAccount.minimumTlsVersion ? storageAccount.minimumTlsVersion : 'TLS1.0'; //Default is TLS 1.0 + let tlsVersion = storageAccount.minimumTlsVersion ? storageAccount.minimumTlsVersion : 'TLS1.2'; //Default is TLS 1.2 tlsVersion = tlsVersion.replace('TLS', ''); tlsVersion = tlsVersion.replace('_', '.'); if (parseFloat(tlsVersion) >= desiredVersion) { helpers.addResult(results, 0, - `Storage Account is using TLS version ${tlsVersion} which is equal to or higher than desired TLS version ${config.sa_min_tls_version}`, + `Storage Account is using TLS version ${tlsVersion} which is equal to or higher than desired TLS version ${sa_min_tls_version}`, location, storageAccount.id); } else { helpers.addResult(results, 2, - `Storage Account is using TLS version ${tlsVersion} which is less than desired TLS version ${config.sa_min_tls_version}`, + `Storage Account is using TLS version ${tlsVersion} which is less than desired TLS version ${sa_min_tls_version}`, location, storageAccount.id); } }); diff --git a/plugins/azure/synapse/synapseWorkspaceAdAuthEnabled.js b/plugins/azure/synapse/synapseWorkspaceAdAuthEnabled.js index 80606b792a..42a942f9d2 100644 --- a/plugins/azure/synapse/synapseWorkspaceAdAuthEnabled.js +++ b/plugins/azure/synapse/synapseWorkspaceAdAuthEnabled.js @@ -2,13 +2,13 @@ var async = require('async'); var helpers = require('../../../helpers/azure'); module.exports = { - title: 'Synapse Workspace AD Auth Enabled', + title: 'Synapse Workspace Entra ID Auth Enabled', category: 'AI & ML', domain: 'Machine Learning', severity: 'Medium', - description: 'Ensures that Azure Synapse workspace has Active Directory (AD) authentication enabled.', - more_info: 'Enabling Azure Active Directory authentication for Synapse workspace enhances security by ensuring that only authenticated and authorized users can access resources and eliminating the need for password storage. This integration simplifies permission management and secure access.', - recommended_action: 'Enable Active Directory (AD) authentication mode for all Synapse workspace.', + description: 'Ensures that Azure Synapse workspace has Entra ID authentication enabled.', + more_info: 'Enabling Azure Entra ID authentication for Synapse workspace enhances security by ensuring that only authenticated and authorized users can access resources and eliminating the need for password storage. This integration simplifies permission management and secure access.', + recommended_action: 'Enable Entra ID authentication mode for all Synapse workspace.', link: 'https://learn.microsoft.com/en-us/azure/synapse-analytics/sql/active-directory-authentication', apis: ['synapse:listWorkspaces'], realtime_triggers: ['microsoftsynapse:workspaces:write','microsoftsynapse:workspaces:delete'], @@ -39,12 +39,11 @@ module.exports = { if (!workspace.id) continue; if (workspace.azureADOnlyAuthentication) { - helpers.addResult(results, 0, 'Synapse workspace has Active Directory authentication enabled', location, workspace.id); + helpers.addResult(results, 0, 'Synapse workspace has Entra ID authentication enabled', location, workspace.id); } else { - helpers.addResult(results, 2, 'Synapse workspace does not have Active Directory authentication enabled', location, workspace.id); + helpers.addResult(results, 2, 'Synapse workspace does not have Entra ID authentication enabled', location, workspace.id); } } - rcb(); }, function() { // Global checking goes here diff --git a/plugins/azure/synapse/synapseWorkspaceAdAuthEnabled.spec.js b/plugins/azure/synapse/synapseWorkspaceAdAuthEnabled.spec.js index 0db95cd9eb..674a19296a 100644 --- a/plugins/azure/synapse/synapseWorkspaceAdAuthEnabled.spec.js +++ b/plugins/azure/synapse/synapseWorkspaceAdAuthEnabled.spec.js @@ -57,23 +57,23 @@ describe('synapseWorkspaceAdAuthEnabled', function () { }); }); - it('should give passing result if workspace has AAD auth enabled', function (done) { + it('should give passing result if workspace has Entra ID auth enabled', function (done) { const cache = createCache([workspaces[0]], null); synapseWorkspaceAdAuthEnabled.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Synapse workspace has Active Directory authentication enabled'); + expect(results[0].message).to.include('Synapse workspace has Entra ID authentication enabled'); expect(results[0].region).to.equal('eastus'); done(); }); }); - it('should give failing result if workspace does not have AAD auth', function (done) { + it('should give failing result if workspace does not have Entra ID auth', function (done) { const cache = createCache([workspaces[1]], null); synapseWorkspaceAdAuthEnabled.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Synapse workspace does not have Active Directory authentication enabled'); + expect(results[0].message).to.include('Synapse workspace does not have Entra ID authentication enabled'); expect(results[0].region).to.equal('eastus'); done(); }); diff --git a/plugins/azure/synapse/workspaceManagedIdentity.js b/plugins/azure/synapse/workspaceManagedIdentity.js index 51e45b6c04..4f46ce60e3 100644 --- a/plugins/azure/synapse/workspaceManagedIdentity.js +++ b/plugins/azure/synapse/workspaceManagedIdentity.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Machine Learning', severity: 'Medium', description: 'Ensure that Azure Synapse workspace 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 Synapse workspace and enable managed identity.', link: 'https://learn.microsoft.com/en-us/azure/synapse-analytics/synapse-service-identity', apis: ['synapse:listWorkspaces'], diff --git a/plugins/azure/virtualmachines/diskByokEncryptionEnabled.js b/plugins/azure/virtualmachines/diskByokEncryptionEnabled.js index 8aba4aa1c5..cf1dbc60d9 100644 --- a/plugins/azure/virtualmachines/diskByokEncryptionEnabled.js +++ b/plugins/azure/virtualmachines/diskByokEncryptionEnabled.js @@ -1,13 +1,13 @@ var async = require('async'); -var helpers = require('../../../helpers/azure/'); +var helpers = require('../../../helpers/azure'); module.exports = { - title: 'Disk Volumes BYOK Encryption Enabled', + title: 'Attached Disk Volumes BYOK Encryption Enabled', category: 'Virtual Machines', domain: 'Compute', severity: 'High', - description: 'Ensures that Azure virtual machine disks have BYOK (Customer-Managed Key) encryption enabled.', + description: 'Ensures that attached Azure virtual machine disks have BYOK (Customer-Managed Key) encryption enabled.', more_info: 'Encrypting virtual machine disk volumes helps protect and safeguard your data to meet organizational security and compliance commitments.', recommended_action: 'Ensure that virtual machine disks are created using BYOK encryption', link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/windows/disk-encryption-key-vault', @@ -35,13 +35,15 @@ module.exports = { } async.each(disks.data, function(disk, scb) { - if (disk.encryption && disk.encryption.type && - (disk.encryption.type === 'EncryptionAtRestWithCustomerKey' || - disk.encryption.type === 'EncryptionAtRestWithPlatformAndCustomerKeys')) { - helpers.addResult(results, 0, 'Disk volume has BYOK encryption enabled', location, disk.id); - } else { - helpers.addResult(results, 2, 'Disk volume has BYOK encryption disabled', location, disk.id); - } + if (disk.diskState && disk.diskState.toLowerCase() === 'attached') { + if (disk.encryption && disk.encryption.type && + (disk.encryption.type === 'EncryptionAtRestWithCustomerKey' || + disk.encryption.type === 'EncryptionAtRestWithPlatformAndCustomerKeys')) { + helpers.addResult(results, 0, 'Disk volume has BYOK encryption enabled', location, disk.id); + } else { + helpers.addResult(results, 2, 'Disk volume has BYOK encryption disabled', location, disk.id); + } + } scb(); }, function() { rcb(); diff --git a/plugins/azure/virtualmachines/diskByokEncryptionEnabled.spec.js b/plugins/azure/virtualmachines/diskByokEncryptionEnabled.spec.js index 6361478c08..8a3afdd7b2 100644 --- a/plugins/azure/virtualmachines/diskByokEncryptionEnabled.spec.js +++ b/plugins/azure/virtualmachines/diskByokEncryptionEnabled.spec.js @@ -7,6 +7,7 @@ const disks = [ 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Compute/disks/test', 'type': 'Microsoft.Compute/disks', 'location': 'eastus', + 'diskState': 'Attached', 'encryption': { 'type': 'EncryptionAtRestWithPlatformKey' } @@ -16,6 +17,7 @@ const disks = [ 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Compute/disks/test', 'type': 'Microsoft.Compute/disks', 'location': 'eastus', + 'diskState': 'Attached', 'encryption': { 'type': 'EncryptionAtRestWithCustomerKey', 'diskEncryptionSetId': '/subscriptions/123/resourceGroups/AQUA-RESOURCE-GROUP/providers/Microsoft.Compute/diskEncryptionSets/test-encrypt-set' @@ -26,10 +28,21 @@ const disks = [ 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Compute/disks/test', 'type': 'Microsoft.Compute/disks', 'location': 'eastus', + 'diskState': 'Attached', 'encryption': { 'type': 'EncryptionAtRestWithPlatformAndCustomerKeys', 'diskEncryptionSetId': '/subscriptions/123/resourceGroups/AQUA-RESOURCE-GROUP/providers/Microsoft.Compute/diskEncryptionSets/test-encrypt-set' } + }, + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Compute/disks/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'diskState': 'Unattached', + 'encryption': { + 'type': 'EncryptionAtRestWithPlatformKey' + } } ]; diff --git a/plugins/azure/virtualmachines/guestLevelDiagnosticsEnabled.js b/plugins/azure/virtualmachines/guestLevelDiagnosticsEnabled.js index d0d43ceb09..c9749a15a7 100644 --- a/plugins/azure/virtualmachines/guestLevelDiagnosticsEnabled.js +++ b/plugins/azure/virtualmachines/guestLevelDiagnosticsEnabled.js @@ -34,13 +34,15 @@ module.exports = { return rcb(); } - for (let virtualMachine of virtualMachines.data) { + for (let virtualMachine of virtualMachines.data) { const virtualMachineData = helpers.addSource(cache, source, ['virtualMachines', 'get', location, virtualMachine.id]); - if (!(virtualMachineData && virtualMachineData.data && virtualMachineData.data.resources && virtualMachineData.data.resources.length) || virtualMachineData.err) { - helpers.addResult(results, 3, 'unable to query for virtual machine data', location, virtualMachine.id); + if (!virtualMachineData || !virtualMachineData.data || virtualMachineData.err) { + helpers.addResult(results, 3, 'Unable to query for virtual machine data', location, virtualMachine.id); } else { - const diagnosticSetting = virtualMachineData.data.resources.find(resource => (resource.properties && resource.properties.settings && resource.properties.settings.ladCfg && resource.properties.settings.ladCfg.diagnosticMonitorConfiguration)); + + const diagnosticSetting = virtualMachineData.data.resources && virtualMachineData.data.resources.length? + virtualMachineData.data.resources.find(resource => (resource.properties && resource.properties.settings && resource.properties.settings.ladCfg && resource.properties.settings.ladCfg.diagnosticMonitorConfiguration)): false; if (diagnosticSetting) { helpers.addResult(results, 0, 'Guest Level Diagnostics are enabled for the virtual machine', location, virtualMachine.id); } else { @@ -55,4 +57,4 @@ module.exports = { callback(null, results, source); }); } -}; \ No newline at end of file +}; diff --git a/plugins/azure/virtualmachines/guestLevelDiagnosticsEnabled.spec.js b/plugins/azure/virtualmachines/guestLevelDiagnosticsEnabled.spec.js index e99167d4af..f852458c73 100644 --- a/plugins/azure/virtualmachines/guestLevelDiagnosticsEnabled.spec.js +++ b/plugins/azure/virtualmachines/guestLevelDiagnosticsEnabled.spec.js @@ -88,11 +88,11 @@ describe('guestLevelDiagnosticsEnabled', function() { }); it('should give unknown result if unable to query for virtual machine details', function(done) { - const cache = createCache([virtualMachines[0]], {}); + const cache = createCache([virtualMachines[0]]); guestLevelDiagnosticsEnabled.run(cache, {}, (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 virtual machine data'); + expect(results[0].message).to.include('Unable to query for virtual machine data'); expect(results[0].region).to.equal('eastus'); done(); }); @@ -120,4 +120,4 @@ describe('guestLevelDiagnosticsEnabled', function() { }); }); }); -}); \ No newline at end of file +}); diff --git a/plugins/azure/virtualmachines/unAttachedDiskByokEncryptionEnabled.js b/plugins/azure/virtualmachines/unAttachedDiskByokEncryptionEnabled.js new file mode 100644 index 0000000000..ed2663b9fa --- /dev/null +++ b/plugins/azure/virtualmachines/unAttachedDiskByokEncryptionEnabled.js @@ -0,0 +1,53 @@ +var async = require('async'); + +var helpers = require('../../../helpers/azure'); + +module.exports = { + title: 'Unattached Disk Volumes BYOK Encryption Enabled', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Medium', + description: 'Ensures that unattached Azure virtual machine disks have BYOK (Customer-Managed Key) encryption enabled.', + more_info: 'Encrypting virtual machine disk volumes helps protect and safeguard your data to meet organizational security and compliance commitments. Having unattached disks with default encryption type can lead to data leakage.', + recommended_action: 'Delete remove unattached disks or enable BYOK encryption for them.', + link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/windows/disk-encryption-key-vault', + apis: ['disks:list'], + realtime_triggers: ['microsoftcompute:disks:write', 'microsoftcompute:disks:delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var locations = helpers.locations(settings.govcloud); + + async.each(locations.disks, function(location, rcb) { + + var disks = helpers.addSource(cache, source, ['disks', 'list', location]); + + if (!disks) return rcb(); + + if (disks.err || !disks.data) { + helpers.addResult(results, 3, 'Unable to query for VM disk volumes: ' + helpers.addError(disks), location); + return rcb(); + } + if (!disks.data.length) { + helpers.addResult(results, 0, 'No existing VM disk volumes found', location); + return rcb(); + } + + for (let disk of disks.data) { + if (!disk.id) continue; + if (disk.diskState && disk.diskState.toLowerCase() === 'unattached') { + if (disk.encryption && disk.encryption.type && + disk.encryption.type === 'EncryptionAtRestWithPlatformKey') { + helpers.addResult(results, 2, 'Unattached disk volume has BYOK encryption disabled', location, disk.id); + } else { + helpers.addResult(results, 0, 'Unattached disk volume has BYOK encryption enabled', location, disk.id); + } + } + } + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; diff --git a/plugins/azure/virtualmachines/unAttachedDiskByokEncryptionEnabled.spec.js b/plugins/azure/virtualmachines/unAttachedDiskByokEncryptionEnabled.spec.js new file mode 100644 index 0000000000..c6a55fea04 --- /dev/null +++ b/plugins/azure/virtualmachines/unAttachedDiskByokEncryptionEnabled.spec.js @@ -0,0 +1,88 @@ +var expect = require('chai').expect; +var diskUnattachedAndDefaultEncryption = require('./unattachedDiskWithDefaultEncryption'); + +const disks = [ + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Compute/disks/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'encryption': { + 'type': 'EncryptionAtRestWithCustomerKey' + }, + 'diskState': 'Reserved' + }, + { + 'name': 'test', + 'id': '/subscriptions/123/resourceGroups/aqua-resource-group/providers/Microsoft.Compute/disks/test', + 'type': 'Microsoft.Compute/disks', + 'location': 'eastus', + 'encryption': { + 'type': 'EncryptionAtRestWithPlatformKey', + 'diskEncryptionSetId': '/subscriptions/123/resourceGroups/AQUA-RESOURCE-GROUP/providers/Microsoft.Compute/diskEncryptionSets/test-encrypt-set' + }, + 'diskState': 'unattached' + } +]; + +const createCache = (disks) => { + const disk = {}; + if (disks) { + disk['data'] = disks; + } + return { + disks: { + list: { + 'eastus': disk + } + } + }; +}; + +describe('diskUnattachedAndDefaultEncryption', function() { + describe('run', function() { + it('should give passing result if no disk volumes found', function(done) { + const cache = createCache([]); + diskUnattachedAndDefaultEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No existing VM disk volumes found'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give unknown result if unable to query for disk volumes', function(done) { + const cache = createCache(); + diskUnattachedAndDefaultEncryption.run(cache, {}, (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 VM disk volumes'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if Disk volume is unattached and encrypted with default encryption key', function(done) { + const cache = createCache([disks[1]]); + diskUnattachedAndDefaultEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Disk volume is unattached and encrypted with default encryption key'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give passing result if Disk volume is attached or encrypted with BYO', function(done) { + const cache = createCache([disks[0]]); + diskUnattachedAndDefaultEncryption.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Disk volume is attached or encrypted with BYOK'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/plugins/azure/virtualmachines/vmAdAuthenticationEnabled.js b/plugins/azure/virtualmachines/vmAdAuthenticationEnabled.js index 734562f992..c72e70bc78 100644 --- a/plugins/azure/virtualmachines/vmAdAuthenticationEnabled.js +++ b/plugins/azure/virtualmachines/vmAdAuthenticationEnabled.js @@ -2,14 +2,14 @@ var async = require('async'); var helpers = require('../../../helpers/azure'); module.exports = { - title: 'VM Active Directory (AD) Authentication Enabled', + title: 'VM Entra ID Authentication Enabled', category: 'Virtual Machines', domain: 'Compute', severity: 'Medium', - description: 'Ensures that Azure Active Directory (AD) authentication is enabled for virtual machines.', - more_info: 'Organizations can now improve the security of virtual machines (VMs) in Azure by integrating with Azure Active Directory (AD) authentication. Enabling Azure Active Directory (AD) authentication for Azure virtual machines (VMs) ensures access to VMs from one central point and simplifies access permission management.', - recommended_action: 'Enable Azure Active Directory (AD) authentication for Azure virtual machines', - link: 'https://learn.microsoft.com/en-us/azure/active-directory/devices/howto-vm-sign-in-azure-ad-windows', + description: 'Ensures that Azure Entra ID authentication is enabled for virtual machines.', + more_info: 'Organizations can now improve the security of virtual machines (VMs) in Azure by integrating with Azure Entra ID authentication. Enabling Azure Entra ID authentication for Azure virtual machines (VMs) ensures access to VMs from one central point and simplifies access permission management.', + recommended_action: 'Enable Azure Entra ID authentication for Azure virtual machines', + link: 'https://learn.microsoft.com/en-us/entra/identity/devices/howto-vm-sign-in-azure-ad-windows', apis: ['virtualMachines:listAll', 'virtualMachineExtensions:list'], realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftcompute:virtualmachines:delete', 'microsoftcompute:virtualmachines:extensions:write', 'microsoftcompute:virtualmachines:extensions:delete'], @@ -44,7 +44,7 @@ module.exports = { } if (!virtualMachineExtensions.data.length) { - helpers.addResult(results, 2, 'Azure Active Directory (AD) authentication is disabled for the virtual machine', location, virtualMachine.id); + helpers.addResult(results, 2, 'Azure Entra ID authentication is disabled for the virtual machine', location, virtualMachine.id); return scb(); } @@ -65,9 +65,9 @@ module.exports = { (!windowsImg && virtualMachineExtension.name && virtualMachineExtension.name === 'AADSSHLoginForLinux'))); if (adEnabled) { - helpers.addResult(results, 0, 'Azure Active Directory (AD) authentication is enabled for the virtual machine', location, virtualMachine.id); + helpers.addResult(results, 0, 'Azure Entra ID authentication is enabled for the virtual machine', location, virtualMachine.id); } else { - helpers.addResult(results, 2, 'Azure Active Directory (AD) authentication is disabled for the virtual machine', location, virtualMachine.id); + helpers.addResult(results, 2, 'Azure Entra ID authentication is disabled for the virtual machine', location, virtualMachine.id); } scb(); diff --git a/plugins/azure/virtualmachines/vmAdAuthenticationEnabled.spec.js b/plugins/azure/virtualmachines/vmAdAuthenticationEnabled.spec.js index 8ef83f17c5..790d8f2f91 100644 --- a/plugins/azure/virtualmachines/vmAdAuthenticationEnabled.spec.js +++ b/plugins/azure/virtualmachines/vmAdAuthenticationEnabled.spec.js @@ -63,7 +63,7 @@ const createCache = (virtualMachines, virtualMachineExtension) => { }; }; -describe('adAuthenticationEnabled', function() { +describe('vmAdAuthenticationEnabled', function() { describe('run', function() { it('should give passing result if no virtual machines', function(done) { const cache = createCache([]); @@ -92,7 +92,7 @@ describe('adAuthenticationEnabled', function() { adAuthenticationEnabled.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Azure Active Directory (AD) authentication is disabled for the virtual machine'); + expect(results[0].message).to.include('Azure Entra ID authentication is disabled for the virtual machine'); expect(results[0].region).to.equal('eastus'); done(); }); @@ -109,34 +109,34 @@ describe('adAuthenticationEnabled', function() { }); }); - it('should give passing result if Azure Active Directory (AD) authentication is enabled for the virtual machine for windows machine', function(done) { + it('should give passing result if Azure Entra ID authentication is enabled for the virtual machine for windows machine', function(done) { const cache = createCache([virtualMachines[0]], [virtualMachineExtension[0]]); adAuthenticationEnabled.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Azure Active Directory (AD) authentication is enabled for the virtual machine'); + expect(results[0].message).to.include('Azure Entra ID authentication is enabled for the virtual machine'); expect(results[0].region).to.equal('eastus'); done(); }); }); - it('should give passing result if Azure Active Directory (AD) authentication is enabled for the virtual machine for linux machine', function(done) { + it('should give passing result if Azure Entra ID authentication is enabled for the virtual machine for linux machine', function(done) { const cache = createCache([virtualMachines[1]], [virtualMachineExtension[1]]); adAuthenticationEnabled.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Azure Active Directory (AD) authentication is enabled for the virtual machine'); + expect(results[0].message).to.include('Azure Entra ID authentication is enabled for the virtual machine'); expect(results[0].region).to.equal('eastus'); done(); }); }); - it('should give failing result if Azure Active Directory (AD) authentication is disabled for the virtual machine', function(done) { + it('should give failing result if Azure Entra ID authentication is disabled for the virtual machine', function(done) { const cache = createCache([virtualMachines[0]], [virtualMachineExtension[1]]); adAuthenticationEnabled.run(cache, { vm_approved_extensions: 'Extension' }, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Azure Active Directory (AD) authentication is disabled for the virtual machine'); + expect(results[0].message).to.include('Azure Entra ID authentication is disabled for the virtual machine'); expect(results[0].region).to.equal('eastus'); done(); }); diff --git a/plugins/azure/virtualmachines/vmAgentEnabled.js b/plugins/azure/virtualmachines/vmAgentEnabled.js index 7c07339381..6d96c80a48 100644 --- a/plugins/azure/virtualmachines/vmAgentEnabled.js +++ b/plugins/azure/virtualmachines/vmAgentEnabled.js @@ -8,9 +8,9 @@ module.exports = { domain: 'Compute', severity: 'Medium', description: 'Ensures that the VM Agent is enabled for virtual machines', - more_info: 'The VM agent must be enabled on Azure virtual machines in order to enable Azure Security Center for data collection.', + more_info: 'The VM agent must be enabled on Azure virtual machines in order to enable Azure Defender for data collection.', recommended_action: 'Enable the VM agent for all virtual machines.', - link: 'https://learn.microsoft.com/en-us/azure/security-center/security-center-enable-vm-agent', + link: 'https://learn.microsoft.com/en-us/azure/defender-for-cloud/enable-agentless-scanning-vms', apis: ['virtualMachines:listAll'], compliance: { hipaa: 'HIPAA requires the logging of all activity ' + diff --git a/plugins/azure/virtualmachines/vmEncryptionAtHost.js b/plugins/azure/virtualmachines/vmEncryptionAtHost.js index 0c15c204a5..a0e4e0bb93 100644 --- a/plugins/azure/virtualmachines/vmEncryptionAtHost.js +++ b/plugins/azure/virtualmachines/vmEncryptionAtHost.js @@ -6,7 +6,7 @@ module.exports = { category: 'Virtual Machines', domain: 'Compute', severity: 'High', - description: 'Ensures that encryption at host is enabled for Azure Virtual Machine disks.', + description: 'Encryption at host ensures that data on Azure Virtual Machine disks- including temporary and cached data- is encrypted at the physical host level before being persisted. This provides end-to-end encryption independent of the guest OS, and does not require Azure Disk Encryption (ADE). Enabling this setting can help meet certain compliance and data residency requirements.', more_info: 'The data for temporary disk and OS/data disk caches is stored on the VM host. Enabling encryption at host for Azure Virtual Machine disks allows the data to be end-to-end encrypted, ensuring compliance and bolstering overall security with Azure Disk Encryption.', recommended_action: 'Ensure that all Azure Virtual Machines have encryption at host enabled for disks.', link: 'https://learn.microsoft.com/en-us/azure/virtual-machines/disk-encryption#encryption-at-host---end-to-end-encryption-for-your-vm-data', diff --git a/plugins/azure/virtualmachines/vmNetworkExposure.js b/plugins/azure/virtualmachines/vmNetworkExposure.js index 4258c9954f..fc237daa37 100644 --- a/plugins/azure/virtualmachines/vmNetworkExposure.js +++ b/plugins/azure/virtualmachines/vmNetworkExposure.js @@ -2,7 +2,7 @@ var async = require('async'); var helpers = require('../../../helpers/azure'); module.exports = { - title: 'Network Exposure', + title: 'Internet Exposure', category: 'Virtual Machines', domain: 'Compute', severity: 'Info', @@ -10,8 +10,8 @@ module.exports = { more_info: 'Virtual machines exposed to the internet are at a higher risk of unauthorized access, data breaches, and cyberattacks. It’s crucial to limit exposure by securing access through proper configuration of security group and firewall rules.', link: 'https://learn.microsoft.com/en-us/azure/security/fundamentals/virtual-machines-overview', recommended_action: 'Secure VM instances by restricting access with properly configured security group and firewall rules.', - apis: ['virtualMachines:listAll', 'networkInterfaces:listAll', 'networkSecurityGroups:listAll', 'virtualNetworks:listAll'], - realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftnetwork:networkinterfaces:write', 'microsoftcompute:virtualmachines:delete', 'microsoftnetwork:networkinterfaces:delete', 'microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete', 'microsoftnetwork:virtualnetworks:write','microsoftnetwork:virtualnetworks:delete'], + apis: ['virtualMachines:listAll', 'networkInterfaces:listAll', 'networkSecurityGroups:listAll', 'virtualNetworks:listAll', 'loadBalancers:listAll'], + realtime_triggers: ['microsoftcompute:virtualmachines:write', 'microsoftnetwork:networkinterfaces:write', 'microsoftcompute:virtualmachines:delete', 'microsoftnetwork:networkinterfaces:delete', 'microsoftnetwork:networksecuritygroups:write','microsoftnetwork:networksecuritygroups:delete', 'microsoftnetwork:virtualnetworks:write','microsoftnetwork:virtualnetworks:delete','microsoftnetwork:loadbalancers:write', 'microsoftnetwork:loadbalancers:delete'], run: function(cache, settings, callback) { var results = []; @@ -58,6 +58,7 @@ module.exports = { virtualMachines.data.forEach(virtualMachine => { let vm_interfaces = []; let securityGroups = []; + let loadBalancers = []; if (virtualMachine.networkProfile && virtualMachine.networkProfile.networkInterfaces && virtualMachine.networkProfile.networkInterfaces.length > 0) { let interfaceIDs = virtualMachine.networkProfile.networkInterfaces.map(nic => nic.id); @@ -83,8 +84,41 @@ module.exports = { } securityGroups = networkSecurityGroups.data.filter(nsg => securityGroupIDs.includes(nsg.id)); } + + // get load balancers + for (let nic of vm_interfaces) { + if (nic.ipConfigurations && nic.ipConfigurations.length) { + nic.ipConfigurations.map(ipConfig => { + if (ipConfig.properties) { + if (ipConfig.properties.loadBalancerInboundNatRules && ipConfig.properties.loadBalancerInboundNatRules.length) { + ipConfig.properties.loadBalancerInboundNatRules.forEach(rule => { + let id = rule.id; + let match = id.match(/\/subscriptions\/.+?(?=\/inboundNatRules)/); + + if (match && match[0]) { + if (!loadBalancers.includes(match[0])) { + loadBalancers.push(match[0]); + } + } + }); + } + if (ipConfig.properties.loadBalancerBackendAddressPools && ipConfig.properties.loadBalancerBackendAddressPools.length) { + ipConfig.properties.loadBalancerBackendAddressPools.forEach(pool => { + let id = pool.id; + let match = id.match(/\/subscriptions\/.+?(?=\/backendAddressPools)/); + if (match && match[0]) { + if (!loadBalancers.includes(match[0])) { + loadBalancers.push(match[0]); + } + } + }); + } + } + }); + } + } } - let internetExposed = helpers.checkNetworkExposure(cache, source, vm_interfaces, securityGroups, location, results); + let internetExposed = helpers.checkNetworkExposure(cache, source, vm_interfaces, securityGroups, location, results, {lbNames: loadBalancers}, virtualMachine); if (internetExposed && internetExposed.length) { helpers.addResult(results, 2, `VM is exposed to the internet through ${internetExposed}`, location, virtualMachine.id); } else { diff --git a/plugins/azure/virtualmachines/vmPrivilegeAnalysis.js b/plugins/azure/virtualmachines/vmPrivilegeAnalysis.js new file mode 100644 index 0000000000..e3818bc8a9 --- /dev/null +++ b/plugins/azure/virtualmachines/vmPrivilegeAnalysis.js @@ -0,0 +1,23 @@ +module.exports = { + title: 'Privilege Analysis', + category: 'Virtual Machines', + domain: 'Compute', + severity: 'Info', + description: 'Ensures that no virtual machines in your Azure environment have excessive permissions.', + more_info: 'Virtual machines that use managed identities with excessive Azure AD permissions may pose security risks. It is a best practice to assign only the necessary permissions to the managed identities attached to virtual machines.', + link: 'https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token', + recommended_action: 'Review and restrict the Azure AD roles associated with managed identities used by virtual machines to follow the principle of least privilege.', + apis: [''], + realtime_triggers: [ + 'Microsoft.Compute/virtualMachines/write', + 'Microsoft.Compute/virtualMachines/delete', + 'Microsoft.ManagedIdentity/userAssignedIdentities/assign/action', + ], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + callback(null, results, source); + }, +}; diff --git a/plugins/azure/virtualmachines/vmWindowsAntiMalwareExtension.js b/plugins/azure/virtualmachines/vmWindowsAntiMalwareExtension.js index 9087dae114..64f4dadbb6 100644 --- a/plugins/azure/virtualmachines/vmWindowsAntiMalwareExtension.js +++ b/plugins/azure/virtualmachines/vmWindowsAntiMalwareExtension.js @@ -1,6 +1,6 @@ var async = require('async'); -var helpers = require('../../../helpers/azure/'); +var helpers = require('../../../helpers/azure'); module.exports = { title: 'VM Windows AntiMalware Extension', @@ -54,7 +54,13 @@ module.exports = { continue; } - let found = virtualMachineExtensions.data.find(vmExt => vmExt.name && vmExt.name.toLowerCase() === 'iaasantimalware'); + let found = virtualMachineExtensions.data.find(vmExt => + vmExt.name && ( + vmExt.name.toLowerCase() === 'iaasantimalware' || + vmExt.name.toLowerCase() === 'iaasantimalwareext' || + vmExt.name.toLowerCase().includes('antimalware') + ) && vmExt.provisioningState === 'Succeeded' + ); if (found) { helpers.addResult(results, 0, 'Windows Virtual Machine has IaaS Antimalware extension installed', location, virtualMachine.id); diff --git a/plugins/azure/virtualmachines/vmWindowsAntiMalwareExtension.spec.js b/plugins/azure/virtualmachines/vmWindowsAntiMalwareExtension.spec.js index ed640fd8ce..3789ed75dc 100644 --- a/plugins/azure/virtualmachines/vmWindowsAntiMalwareExtension.spec.js +++ b/plugins/azure/virtualmachines/vmWindowsAntiMalwareExtension.spec.js @@ -13,12 +13,26 @@ const virtualMachineExtension = [ { 'name': 'TestExtension', 'id': '/subscriptions/123/resourceGroups/AQUA-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm/extensions/TestExtension', - 'type': 'Microsoft.Compute/virtualMachines/extensions' + 'type': 'Microsoft.Compute/virtualMachines/extensions', + 'provisioningState': 'Succeeded' }, { 'name': 'IaaSAntimalware', - 'id': '/subscriptions/123/resourceGroups/AQUA-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm/extensions/TestExtension', - 'type': 'Microsoft.Compute/virtualMachines/extensions' + 'id': '/subscriptions/123/resourceGroups/AQUA-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm/extensions/IaaSAntimalware', + 'type': 'Microsoft.Compute/virtualMachines/extensions', + 'provisioningState': 'Succeeded' + }, + { + 'name': 'IaaSAntiMalwareExt', + 'id': '/subscriptions/123/resourceGroups/AQUA-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm/extensions/IaaSAntiMalwareExt', + 'type': 'Microsoft.Compute/virtualMachines/extensions', + 'provisioningState': 'Succeeded' + }, + { + 'name': 'IaaSAntimalware', + 'id': '/subscriptions/123/resourceGroups/AQUA-RESOURCE_GROUP/providers/Microsoft.Compute/virtualMachines/test-vm/extensions/IaaSAntimalware', + 'type': 'Microsoft.Compute/virtualMachines/extensions', + 'provisioningState': 'Failed' } ]; @@ -104,6 +118,28 @@ describe('vmWindowsAntiMalwareExtension', function() { }); }); + it('should give passing result if windows vm has IaaSAntiMalwareExt extension installed', function(done) { + const cache = createCache([virtualMachines[0]], [virtualMachineExtension[2]]); + vmWindowsAntiMalwareExtension.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Windows Virtual Machine has IaaS Antimalware extension installed'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + + it('should give failing result if antimalware extension has failed provisioning state', function(done) { + const cache = createCache([virtualMachines[0]], [virtualMachineExtension[3]]); + vmWindowsAntiMalwareExtension.run(cache, {}, (err, results) => { + expect(results.length).to.equal(1); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Windows Virtual Machine does not have IaaS Antimalware extension installed'); + expect(results[0].region).to.equal('eastus'); + done(); + }); + }); + it('should give failing result if windows vm not have antimalware extension installed', function(done) { const cache = createCache([virtualMachines[0]], [virtualMachineExtension[0]]); vmWindowsAntiMalwareExtension.run(cache, {}, (err, results) => { diff --git a/plugins/azure/virtualmachinescaleset/scaleSetAdAuthEnabled.js b/plugins/azure/virtualmachinescaleset/scaleSetAdAuthEnabled.js index fb5bbdc846..70451d48e9 100644 --- a/plugins/azure/virtualmachinescaleset/scaleSetAdAuthEnabled.js +++ b/plugins/azure/virtualmachinescaleset/scaleSetAdAuthEnabled.js @@ -2,13 +2,13 @@ const async = require('async'); const helpers = require('../../../helpers/azure'); module.exports = { - title: 'Scale Sets AD Authentication Enabled', + title: 'Scale Sets Entra ID Authentication Enabled', category: 'Virtual Machine Scale Set', domain: 'Compute', severity: 'Medium', - description: 'Ensures that Azure Active Directory (AD) authentication is enabled for Virtual Machine Scale Sets.', - more_info: 'Enabling Azure Active Directory (AD) authentication for VM Scale Sets ensures access from one central point and simplifies access permission management. It allows conditional access by using Role-Based Access Control (RBAC) policies, and enable MFA.', - recommended_action: 'Enable Active Directory authentication for all Virtual Machines scale sets.', + description: 'Ensures that Azure Entra ID authentication is enabled for Virtual Machine Scale Sets.', + more_info: 'Enabling Azure Entra ID authentication for VM Scale Sets ensures access from one central point and simplifies access permission management. It allows conditional access by using Role-Based Access Control (RBAC) policies, and enable MFA.', + recommended_action: 'Enable Entra ID authentication for all Virtual Machines scale sets.', link: 'https://learn.microsoft.com/en-us/entra/identity/devices/howto-vm-sign-in-azure-ad-linux', apis: ['virtualMachineScaleSets:listAll'], realtime_triggers: ['microsoftcompute:virtualmachinescalesets:write', 'microsoftcompute:virtualmachinescalesets:delete', 'microsoftcompute:virtualmachinescalesets:extensions:write', 'microsoftcompute:virtualmachinescalesets:extensions:delete'], @@ -51,10 +51,10 @@ module.exports = { if (adAuthentication) { helpers.addResult(results, 0, - 'Virtual Machine Scale Set has Active Directory authentication enabled', location, virtualMachineScaleSet.id); + 'Virtual Machine Scale Set has Entra ID authentication enabled', location, virtualMachineScaleSet.id); } else { helpers.addResult(results, 2, - 'Virtual Machine Scale Set has Active Directory authentication disabled', location, virtualMachineScaleSet.id); + 'Virtual Machine Scale Set has Entra ID authentication disabled', location, virtualMachineScaleSet.id); } } rcb(); diff --git a/plugins/azure/virtualmachinescaleset/scaleSetAdAuthEnabled.spec.js b/plugins/azure/virtualmachinescaleset/scaleSetAdAuthEnabled.spec.js index 0348115352..1615cb1b1b 100644 --- a/plugins/azure/virtualmachinescaleset/scaleSetAdAuthEnabled.spec.js +++ b/plugins/azure/virtualmachinescaleset/scaleSetAdAuthEnabled.spec.js @@ -92,33 +92,33 @@ describe('scaleSetAdAuthEnabled', function() { }); }); - it('should give passing result if linux Virtual Machine Scale Set has AD authentication enabled', function(done) { + it('should give passing result if linux Virtual Machine Scale Set has Entra ID authentication enabled', function(done) { const cache = createCache([virtualMachineScaleSets[0]]); scaleSetAdAuthEnabled.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Virtual Machine Scale Set has Active Directory authentication enabled'); + expect(results[0].message).to.include('Virtual Machine Scale Set has Entra ID authentication enabled'); expect(results[0].region).to.equal('eastus'); done(); }); }); - it('should give passing result if windows Virtual Machine Scale Set has AD authentication enabled', function(done) { + it('should give passing result if windows Virtual Machine Scale Set has Entra ID authentication enabled', function(done) { const cache = createCache([virtualMachineScaleSets[1]]); scaleSetAdAuthEnabled.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Virtual Machine Scale Set has Active Directory authentication enabled'); + expect(results[0].message).to.include('Virtual Machine Scale Set has Entra ID authentication enabled'); expect(results[0].region).to.equal('eastus'); done(); }); }); - it('should give failing result if Virtual Machine Scale Set has AD authentication disabled', function(done) { + it('should give failing result if Virtual Machine Scale Set has Entra ID authentication disabled', function(done) { const cache = createCache([virtualMachineScaleSets[2]]); scaleSetAdAuthEnabled.run(cache, {}, (err, results) => { expect(results.length).to.equal(1); expect(results[0].status).to.equal(2); - expect(results[0].message).to.include('Virtual Machine Scale Set has Active Directory authentication disabled'); + expect(results[0].message).to.include('Virtual Machine Scale Set has Entra ID authentication disabled'); expect(results[0].region).to.equal('eastus'); done(); }); diff --git a/plugins/azure/virtualmachinescaleset/vmssManagedIdentityEnabled.js b/plugins/azure/virtualmachinescaleset/vmssManagedIdentityEnabled.js index cc109e3bf6..4ddb27d33d 100644 --- a/plugins/azure/virtualmachinescaleset/vmssManagedIdentityEnabled.js +++ b/plugins/azure/virtualmachinescaleset/vmssManagedIdentityEnabled.js @@ -7,7 +7,7 @@ module.exports = { domain: 'Compute', severity: 'Medium', description: 'Ensures that Azure Virtual Machine Scale Sets 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/entra/identity/managed-identities-azure-resources/qs-configure-portal-windows-vmss', recommended_action: 'Modify VM Scale Set and enable managed identity.', apis: ['virtualMachineScaleSets:listAll'], diff --git a/plugins/google/cloudfunctions/cloudFunctionNetworkExposure.js b/plugins/google/cloudfunctions/cloudFunctionNetworkExposure.js new file mode 100644 index 0000000000..f05a4b4d42 --- /dev/null +++ b/plugins/google/cloudfunctions/cloudFunctionNetworkExposure.js @@ -0,0 +1,159 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Internet Exposure', + category: 'Cloud Functions', + domain: 'Serverless', + severity: 'Info', + description: 'Ensures Cloud Functions are not publicly exposed to all inbound traffic.', + more_info: 'Cloud Functions should be properly secured using ingress settings and load balancer configurations to control which sources can invoke the function.', + link: 'https://cloud.google.com/functions/docs/networking/network-settings', + recommended_action: 'Modify the Cloud Function to restrict ingress settings and ensure load balancer and api gateway configurations are properly secured.', + apis: ['functions:list', 'urlMaps:list', 'targetHttpProxies:list', 'targetHttpsProxies:list', + 'forwardingRules:list', 'backendServices:list', 'apiGateways:list', 'api:list', 'apiConfigs:list', 'apiGateways:getIamPolicy'], + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction', 'functions.CloudFunctionsService.CreateFunction', 'functions.CloudFunctionsService.DeleteFunction', + 'compute.backendServices.insert', 'compute.backendServices.delete', 'compute.backendServices.patch', 'compute.instanceGroups.removeInstances', 'compute.urlMaps.insert', 'compute.urlMaps.delete', 'compute.urlMaps.update', 'compute.urlMaps.patch', + 'compute.targetHttpProxies.insert', 'compute.targetHttpProxies.delete', 'compute.targetHttpProxies.patch', 'compute.targetHttpsProxies.insert', 'compute.targetHttpsProxies.delete', 'compute.targetHttpsProxies.patch', + 'compute.forwardingRules.insert', 'compute.forwardingRules.delete', 'compute.forwardingRules.patch', 'apigateway.gateways.create', 'apigateway.gateways.update', 'apigateway.gateways.delete' + ], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects', 'get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + let apiGateways = [], apis = [], apiConfigs = []; + for (let region of regions.apiGateways) { + var gateways = helpers.addSource(cache, source, + ['apiGateways', 'list', region]); + + if (gateways && !gateways.err && gateways.data && gateways.data.length) { + apiGateways = apiGateways.concat(gateways.data); + } + + + var apiList = helpers.addSource(cache, source, + ['api', 'list', region]); + + if (apiList && !apiList.err && apiList.data && apiList.data.length) { + apis = apis.concat(apiList.data); + } + + var configs = helpers.addSource(cache, source, + ['apiConfigs', 'list', region]); + + if (configs && !configs.err && configs.data && configs.data.length) { + apiConfigs = apiConfigs.concat(configs.data); + } + } + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functions', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud Functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + functions.data.forEach(func => { + if (!func.name) return; + let internetExposed = ''; + if (func.ingressSettings && func.ingressSettings.toUpperCase() == 'ALLOW_ALL') { + internetExposed = 'public access'; + } else if (func.ingressSettings && func.ingressSettings.toUpperCase() == 'ALLOW_INTERNAL_AND_GCLB') { + // only check load balancer flow if it allows traffic from LBs + let forwardingRules = []; + let networks = []; + let firewallRules = []; + forwardingRules = helpers.getForwardingRules(cache, source, region, func); + internetExposed = helpers.checkNetworkExposure(cache, source, networks, firewallRules, region, results, forwardingRules); + + if (!internetExposed || !internetExposed.length) { + const gatewayPolicies = helpers.addSource(cache, source, + ['apiGateways', 'getIamPolicy', region]); + + if (apiGateways && apiGateways.length && apiConfigs && apiConfigs.length) { + apiGateways.forEach(gateway => { + let isGatewayExposed = false; + if (!gateway.apiConfig || !gateway.defaultHostname) return; + + const apiConfig = apiConfigs.find(config => + gateway.apiConfig.includes(config.name)); + + if (!apiConfig) return; + + if (apiConfig.openapiDocuments) { + const specs = apiConfig.openapiDocuments.map(doc => + typeof doc === 'string' ? JSON.parse(doc) : doc); + + const hasFunctionReference = specs.some(spec => + JSON.stringify(spec).includes(func.httpsTrigger.url) || + JSON.stringify(spec).includes(func.name) + ); + + if (!hasFunctionReference) return; + + const gatewayPolicy = gatewayPolicies.data.find(policy => + policy.parent && policy.parent.name === gateway.name); + + if (gatewayPolicy && gatewayPolicy.bindings) { + const publicAccess = gatewayPolicy.bindings.some(binding => + binding.members.includes('allUsers') || + binding.members.includes('allAuthenticatedUsers')); + if (publicAccess) { + isGatewayExposed = true; + } + } + + if (!apiConfig.securityDefinitions || !Object.keys(apiConfig.securityDefinitions).length || + !apiConfig.security || !apiConfig.security.length) { + isGatewayExposed = true; + } + + + if (isGatewayExposed) { + internetExposed += internetExposed.length ? `, ag ${gateway.displayName}` : `ag ${gateway.displayName}`; + } + } + }); + } + } + + } + + if (internetExposed && internetExposed.length) { + helpers.addResult(results, 2, `Cloud function is exposed to the internet through ${internetExposed}`, region, func.name); + } else { + helpers.addResult(results, 0, 'Cloud function is not exposed to the internet', region, func.name); + } + + + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + + } +}; + diff --git a/plugins/google/cloudfunctions/cloudFunctionsPrivilegeAnalysis.js b/plugins/google/cloudfunctions/cloudFunctionsPrivilegeAnalysis.js new file mode 100644 index 0000000000..0913ea8597 --- /dev/null +++ b/plugins/google/cloudfunctions/cloudFunctionsPrivilegeAnalysis.js @@ -0,0 +1,23 @@ +module.exports = { + title: 'Privilege Analysis', + category: 'Cloud Functions', + domain: 'Cloud Functions', + severity: 'Info', + description: 'Ensures that no Cloud Functions in your cloud environment have excessive permissions.', + more_info: 'Cloud Functions that use service accounts with excessive IAM permissions may pose security risks. It is a best practice to assign only the necessary permissions to the service accounts attached to functions.', + link: 'https://cloud.google.com/functions/docs/securing/authenticating', + recommended_action: 'Review and restrict the IAM roles associated with service accounts used by Cloud Functions to follow the principle of least privilege.', + apis: [''], + realtime_triggers: [ + 'functions.CloudFunctionsService.UpdateFunction', + 'functions.CloudFunctionsService.CreateFunction', + 'functions.CloudFunctionsService.DeleteFunction' + ], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + callback(null, results, source); + } +}; diff --git a/plugins/google/cloudfunctionsv2/cloudFunctionV2HttpsOnly.js b/plugins/google/cloudfunctionsv2/cloudFunctionV2HttpsOnly.js new file mode 100644 index 0000000000..00cecfef9f --- /dev/null +++ b/plugins/google/cloudfunctionsv2/cloudFunctionV2HttpsOnly.js @@ -0,0 +1,66 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'HTTP Trigger Require HTTPS V2', + category: 'Cloud Functions', + domain: 'Serverless', + severity: 'Medium', + description: 'Ensure that Cloud Functions V2 are configured to require HTTPS for HTTP invocations.', + more_info: 'You can make your Google Cloud Functions V2 calls secure by making sure that they require HTTPS.', + link: 'https://cloud.google.com/functions/docs/writing/http', + recommended_action: 'Ensure that your Google Cloud Functions V2 always require HTTPS.', + apis: ['functionsv2:list'], + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction','functions.CloudFunctionsService.DeleteFunction', 'functions.CloudFunctionsService.CreateFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functionsv2', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + functions.data.forEach(funct => { + if (!funct.name) return; + + if (!funct.environment || funct.environment !== 'GEN_2') return; + + let serviceConfig = funct.serviceConfig || {}; + + if (serviceConfig.uri) { + if (serviceConfig.securityLevel && serviceConfig.securityLevel == 'SECURE_ALWAYS') { + helpers.addResult(results, 0, + 'Cloud Function is configured to require HTTPS for HTTP invocations', region, funct.name); + } else { + helpers.addResult(results, 2, + 'Cloud Function is not configured to require HTTPS for HTTP invocations', region, funct.name); + } + } else { + helpers.addResult(results, 0, + 'Cloud Function trigger type is not HTTP', region, funct.name); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } + +}; + diff --git a/plugins/google/cloudfunctionsv2/cloudFunctionV2HttpsOnly.spec.js b/plugins/google/cloudfunctionsv2/cloudFunctionV2HttpsOnly.spec.js new file mode 100644 index 0000000000..0d7d2b42f6 --- /dev/null +++ b/plugins/google/cloudfunctionsv2/cloudFunctionV2HttpsOnly.spec.js @@ -0,0 +1,177 @@ +var expect = require('chai').expect; +var plugin = require('./cloudFunctionV2HttpsOnly'); + + +const functions = [ + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "uri": "https://us-central1-my-test-project.cloudfunctions.net/function-1", + "securityLevel": "SECURE_OPTIONAL" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-2", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "uri": "https://us-central1-my-test-project.cloudfunctions.net/function-2", + "securityLevel": "SECURE_ALWAYS" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-3", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "handleEvent" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-4", + "environment": "GEN_1", + "state": "ACTIVE", + "runtime": "nodejs14", + "httpsTrigger": { + "url": "https://us-central1-my-test-project.cloudfunctions.net/function-4", + "securityLevel": "SECURE_OPTIONAL" + } + } +]; + +const createCache = (list, err) => { + return { + functionsv2: { + list: { + 'us-central1': { + err: err, + data: list + } + } + } + } +}; + +describe('httpTriggerRequireHttps', function () { + describe('run', function () { + it('should give passing result if no Cloud Functions V2 found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Google Cloud functions found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Google Cloud functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Google Cloud functions'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Cloud Function is configured to require HTTPS for HTTP invocations', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cloud Function is configured to require HTTPS for HTTP invocations'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [functions[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Cloud Function is not configured to require HTTPS for HTTP invocations', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cloud Function is not configured to require HTTPS for HTTP invocations'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Cloud Function trigger type is not HTTP', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cloud Function trigger type is not HTTP'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[2]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should not check Gen 1 functions in v2 API response', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(0); + done(); + }; + + const cache = createCache( + [functions[3]], + null + ); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/cloudfunctionsv2/cloudFunctionV2IngressSettings.js b/plugins/google/cloudfunctionsv2/cloudFunctionV2IngressSettings.js new file mode 100644 index 0000000000..78219be8b3 --- /dev/null +++ b/plugins/google/cloudfunctionsv2/cloudFunctionV2IngressSettings.js @@ -0,0 +1,65 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Ingress All Traffic Disabled V2', + category: 'Cloud Functions', + domain: 'Serverless', + severity: 'Medium', + description: 'Ensure that Cloud Functions V2 are configured to allow only internal traffic or traffic from Cloud Load Balancer.', + more_info: 'You can secure your Google Cloud Functions V2 by implementing network-based access control.', + link: 'https://cloud.google.com/functions/docs/securing/authenticating', + recommended_action: 'Ensure that your Google Cloud Functions V2 do not allow external traffic from the internet.', + apis: ['functionsv2:list'], + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction', 'functions.CloudFunctionsService.CreateFunction', 'functions.CloudFunctionsService.DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functionsv2', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + functions.data.forEach(func => { + if (!func.name) return; + + if (!func.environment || func.environment !== 'GEN_2') return; + + let ingressSettings = func.serviceConfig && func.serviceConfig.ingressSettings + ? func.serviceConfig.ingressSettings + : null; + + if (ingressSettings && ingressSettings.toUpperCase() == 'ALLOW_ALL') { + helpers.addResult(results, 2, + 'Cloud Function is configured to allow all traffic', region, func.name); + } else if (ingressSettings) { + helpers.addResult(results, 0, + 'Cloud Function is configured to allow only internal and CLB traffic', region, func.name); + } else { + helpers.addResult(results, 2, + 'Cloud Function does not have ingress settings configured', region, func.name); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/cloudfunctionsv2/cloudFunctionV2IngressSettings.spec.js b/plugins/google/cloudfunctionsv2/cloudFunctionV2IngressSettings.spec.js new file mode 100644 index 0000000000..13168f81e7 --- /dev/null +++ b/plugins/google/cloudfunctionsv2/cloudFunctionV2IngressSettings.spec.js @@ -0,0 +1,172 @@ +var expect = require('chai').expect; +var plugin = require('./cloudFunctionV2IngressSettings'); + + +const functions = [ + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "ingressSettings": "ALLOW_ALL" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-2", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "ingressSettings": "ALLOW_INTERNAL_AND_GCLB" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-3", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-4", + "environment": "GEN_1", + "state": "ACTIVE", + "runtime": "nodejs14", + "ingressSettings": "ALLOW_ALL" + } +]; + +const createCache = (list, err) => { + return { + functionsv2: { + list: { + 'us-central1': { + err: err, + data: list + } + } + } + } +}; + +describe('ingressAllTrafficDisabled', function () { + describe('run', function () { + it('should give passing result if no Cloud Functions V2 found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Google Cloud functions found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Google Cloud functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Google Cloud functions'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Cloud Function is configured to allow only internal and CLB traffic', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cloud Function is configured to allow only internal and CLB traffic'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [functions[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Cloud Function is configured to allow all traffic', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cloud Function is configured to allow all traffic'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Cloud Function does not have ingress settings configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cloud Function does not have ingress settings configured'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[2]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should not check Gen 1 functions in v2 API response', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(0); + done(); + }; + + const cache = createCache( + [functions[3]], + null + ); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/cloudfunctionsv2/cloudFunctionV2LabelsAdded.js b/plugins/google/cloudfunctionsv2/cloudFunctionV2LabelsAdded.js new file mode 100644 index 0000000000..3d7bdd6444 --- /dev/null +++ b/plugins/google/cloudfunctionsv2/cloudFunctionV2LabelsAdded.js @@ -0,0 +1,58 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Cloud Function V2 Labels Added', + category: 'Cloud Functions', + domain: 'Serverless', + severity: 'Low', + description: 'Ensure that all Cloud Functions V2 have labels added.', + more_info: 'Labels are a lightweight way to group resources together that are related to or associated with each other. It is a best practice to label cloud resources to better organize and gain visibility into their usage.', + link: 'https://cloud.google.com/functions/docs/configuring', + recommended_action: 'Ensure labels are added to all Cloud Functions V2.', + apis: ['functionsv2:list'], + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction', 'functions.CloudFunctionsService.CreateFunction', 'functions.CloudFunctionsService.DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functionsv2', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + functions.data.forEach(func => { + if (!func.name) return; + + if (!func.environment || func.environment !== 'GEN_2') return; + + if (func.labels && Object.keys(func.labels).length) { + helpers.addResult(results, 0, + `${Object.keys(func.labels).length} labels found for Cloud Function`, region, func.name); + } else { + helpers.addResult(results, 2, + 'Cloud Function does not have any labels', region, func.name); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/cloudfunctionsv2/cloudFunctionV2LabelsAdded.spec.js b/plugins/google/cloudfunctionsv2/cloudFunctionV2LabelsAdded.spec.js new file mode 100644 index 0000000000..2bf55b1c37 --- /dev/null +++ b/plugins/google/cloudfunctionsv2/cloudFunctionV2LabelsAdded.spec.js @@ -0,0 +1,143 @@ +var expect = require('chai').expect; +var plugin = require('./cloudFunctionV2LabelsAdded'); + + +const functions = [ + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "ingressSettings": "ALLOW_ALL" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-2", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "ingressSettings": "ALLOW_INTERNAL_AND_GCLB" + }, + "labels": { 'deployment-tool': 'console-cloud', 'env': 'production' } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-3", + "environment": "GEN_1", + "state": "ACTIVE", + "runtime": "nodejs14", + "ingressSettings": "ALLOW_ALL" + } +]; + +const createCache = (list, err) => { + return { + functionsv2: { + list: { + 'us-central1': { + err: err, + data: list + } + } + } + } +}; + +describe('cloudFunctionLabelsAdded', function () { + describe('run', function () { + it('should give passing result if no Google Cloud functions found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Google Cloud functions found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Google Cloud functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Google Cloud functions'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Cloud Function has labels added', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('labels found for Cloud Function'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [functions[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Cloud Function does not have labels added', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('does not have any labels'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should not check Gen 1 functions in v2 API response', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(0); + done(); + }; + + const cache = createCache( + [functions[2]], + null + ); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/cloudfunctionsv2/cloudFunctionV2OldRuntime.js b/plugins/google/cloudfunctionsv2/cloudFunctionV2OldRuntime.js new file mode 100644 index 0000000000..424fd12b6d --- /dev/null +++ b/plugins/google/cloudfunctionsv2/cloudFunctionV2OldRuntime.js @@ -0,0 +1,129 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Cloud Function V2 Old Runtimes', + category: 'Cloud Functions', + domain: 'Compute', + severity: 'Medium', + description: 'Ensure that Cloud Functions V2 are not using deprecated runtime versions.', + more_info: 'Cloud Functions V2 runtimes should be kept current with recent versions of the underlying codebase. It is recommended to update to the latest supported versions to avoid potential security risks and ensure compatibility.', + link: 'https://cloud.google.com/functions/docs/concepts/execution-environment', + recommended_action: 'Modify Cloud Functions V2 to use latest versions.', + apis: ['functionsv2:list'], + settings: { + function_runtime_fail: { + name: 'Cloud Function V2 Runtime Fail', + description: 'Return a failing result for Cloud Function V2 runtime before this number of days for their end of life date.', + regex: '^[1-9]{1}[0-9]{0,3}$', + default: 0 + } + }, + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction', 'functions.CloudFunctionsService.CreateFunction', 'functions.CloudFunctionsService.DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + var config = { + function_runtime_fail: parseInt(settings.function_runtime_fail || this.settings.function_runtime_fail.default) + }; + + var deprecatedRuntimes = [ + { 'id':'nodejs10', 'name': 'Node.js 10.x', 'endOfLifeDate': '2021-07-30' }, + { 'id':'nodejs12', 'name': 'Node.js 12', 'endOfLifeDate': '2024-01-30' }, + { 'id':'nodejs14', 'name': 'Node.js 14', 'endOfLifeDate': '2024-01-30' }, + { 'id':'nodejs16', 'name': 'Node.js 16', 'endOfLifeDate': '2024-01-30' }, + { 'id':'nodejs18', 'name': 'Node.js 18', 'endOfLifeDate': '2025-04-30' }, + { 'id':'nodejs20', 'name': 'Node.js 20', 'endOfLifeDate': '2026-04-30' }, + { 'id':'dotnet6', 'name': '.Net 6', 'endOfLifeDate': '2024-11-12' }, + { 'id':'dotnet7', 'name': '.Net 7', 'endOfLifeDate': '2024-05-14' }, + { 'id':'dotnet3', 'name': '.Net Core 3', 'endOfLifeDate': '2024-01-30' }, + { 'id':'python27', 'name': 'Python 2.7', 'endOfLifeDate': '2021-07-15' }, + { 'id':'python36', 'name': 'Python 3.6', 'endOfLifeDate': '2022-07-18' }, + { 'id':'python37', 'name': 'Python 3.7', 'endOfLifeDate': '2024-01-30' }, + { 'id':'python38', 'name': 'Python 3.8', 'endOfLifeDate': '2024-10-14' }, + { 'id':'python39', 'name': 'Python 3.9', 'endOfLifeDate': '2025-10-05' }, + { 'id':'python310', 'name': 'Python 3.10', 'endOfLifeDate': '2026-10-04' }, + { 'id':'python311', 'name': 'Python 3.11', 'endOfLifeDate': '2027-10-24' }, + { 'id':'python312', 'name': 'Python 3.12', 'endOfLifeDate': '2028-10-02' }, + { 'id':'ruby25', 'name': 'Ruby 2.5', 'endOfLifeDate': '2021-07-30' }, + { 'id':'ruby27', 'name': 'Ruby 2.7', 'endOfLifeDate': '2024-01-30' }, + { 'id':'ruby30', 'name': 'Ruby 3.0', 'endOfLifeDate': '2024-03-31' }, + { 'id':'ruby32', 'name': 'Ruby 3.2', 'endOfLifeDate': '2026-03-31' }, + { 'id':'go121', 'name': 'Go 1.21', 'endOfLifeDate': '2024-05-01' }, + { 'id':'go119', 'name': 'Go 1.19', 'endOfLifeDate': '2024-04-30' }, + { 'id':'go118', 'name': 'Go 1.18', 'endOfLifeDate': '2024-01-30' }, + { 'id':'go116', 'name': 'Go 1.16', 'endOfLifeDate': '2024-01-30' }, + { 'id':'go113', 'name': 'Go 1.13', 'endOfLifeDate': '2024-01-30' }, + { 'id':'java8', 'name': 'Java 8', 'endOfLifeDate': '2024-01-08' }, + { 'id':'java11', 'name': 'Java 11', 'endOfLifeDate': '2024-10-01' }, + { 'id':'java17', 'name': 'Java 17', 'endOfLifeDate': '2027-10-01' }, + { 'id':'php74', 'name': 'PHP 7.4', 'endOfLifeDate': '2024-01-30' }, + { 'id':'php81', 'name': 'PHP 8.1', 'endOfLifeDate': '2024-11-25' }, + { 'id':'php82', 'name': 'PHP 8.2', 'endOfLifeDate': '2025-12-08' }, + ]; + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functionsv2', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + functions.data.forEach(func => { + if (!func.name) return; + + if (!func.environment || func.environment !== 'GEN_2') return; + + let buildConfig = func.buildConfig || {}; + let runtime = buildConfig.runtime; + + if (!runtime) { + helpers.addResult(results, 2, + 'Cloud Function does not have a runtime configured', region, func.name); + return; + } + + var deprecatedRuntime = deprecatedRuntimes.filter((d) => { + return d.id == runtime; + }); + + var version = runtime; + var runtimeDeprecationDate = (deprecatedRuntime && deprecatedRuntime.length && deprecatedRuntime[0].endOfLifeDate) ? Date.parse(deprecatedRuntime[0].endOfLifeDate) : null; + let today = new Date(); + today = Date.parse(`${today.getFullYear()}-${today.getMonth()+1}-${today.getDate()}`); + var difference = runtimeDeprecationDate? Math.round((runtimeDeprecationDate - today)/(1000 * 3600 * 24)): null; + if (runtimeDeprecationDate && today > runtimeDeprecationDate) { + helpers.addResult(results, 2, + 'Cloud Function is using runtime: ' + deprecatedRuntime[0].name + ' which was deprecated on: ' + deprecatedRuntime[0].endOfLifeDate, + region, func.name); + } else if (difference && config.function_runtime_fail >= difference) { + helpers.addResult(results, 2, + 'Cloud Function is using runtime: ' + version + ' which is deprecating in ' + Math.abs(difference) + ' days', + region, func.name); + } else { + helpers.addResult(results, 0, + 'Cloud Function is running the current version: ' + version, + region, func.name); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/cloudfunctionsv2/cloudFunctionV2OldRuntime.spec.js b/plugins/google/cloudfunctionsv2/cloudFunctionV2OldRuntime.spec.js new file mode 100644 index 0000000000..8aa9953135 --- /dev/null +++ b/plugins/google/cloudfunctionsv2/cloudFunctionV2OldRuntime.spec.js @@ -0,0 +1,168 @@ +var expect = require('chai').expect; +var plugin = require('./cloudFunctionV2OldRuntime'); + + +const functions = [ + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs14", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "ingressSettings": "ALLOW_ALL" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-2", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "python312", + "entryPoint": "main" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "ingressSettings": "ALLOW_INTERNAL_AND_GCLB" + }, + "labels": { 'deployment-tool': 'console-cloud' } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-3", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-4", + "environment": "GEN_1", + "state": "ACTIVE", + "runtime": "nodejs14" + } +]; + +const createCache = (list, err) => { + return { + functionsv2: { + list: { + 'us-central1': { + err: err, + data: list + } + } + } + } +}; + +describe('cloudFunctionOldRuntime', function () { + describe('run', function () { + it('should give passing result if no Cloud Functions V2 found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Google Cloud functions found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Google Cloud functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Google Cloud functions'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Cloud Function is using latest runtime version', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cloud Function is running the current version: '); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [functions[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Cloud Function is using deprecated runtime version', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('which was deprecated on'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Cloud Function does not have a runtime configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cloud Function does not have a runtime configured'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[2]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should not check Gen 1 functions in v2 API response', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(0); + done(); + }; + + const cache = createCache( + [functions[3]], + null + ); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/cloudfunctionsv2/cloudFunctionV2VPCConnector.js b/plugins/google/cloudfunctionsv2/cloudFunctionV2VPCConnector.js new file mode 100644 index 0000000000..075a94fb49 --- /dev/null +++ b/plugins/google/cloudfunctionsv2/cloudFunctionV2VPCConnector.js @@ -0,0 +1,67 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Cloud Function V2 Serverless VPC Access', + category: 'Cloud Functions', + domain: 'Serverless', + severity: 'High', + description: 'Ensure that Cloud Functions V2 are allowed to access only VPC resources.', + more_info: 'Cloud Functions V2 may require to connect directly to Compute Engine VM instances, Memorystore instances, Cloud SQL instances, and any other resources. It is a best practice to send requests to these resources using an internal IP address by connecting to VPC network using "Serverless VPC Access" configuration.', + link: 'https://cloud.google.com/functions/docs/networking/connecting-vpc#create-connector', + recommended_action: 'Ensure all Cloud Functions V2 are using serverless VPC connectors.', + apis: ['functionsv2:list'], + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction', 'functions.CloudFunctionsService.CreateFunction', 'functions.CloudFunctionsService.DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functionsv2', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + functions.data.forEach(func => { + if (!func.name) return; + + if (!func.environment || func.environment !== 'GEN_2') return; + + let serviceConfig = func.serviceConfig || {}; + let vpcConnector = serviceConfig.vpcConnector; + let vpcConnectorEgressSettings = serviceConfig.vpcConnectorEgressSettings; + + if (vpcConnector) { + if (vpcConnectorEgressSettings && vpcConnectorEgressSettings.toUpperCase() === 'ALL_TRAFFIC') { + helpers.addResult(results, 0, + 'Cloud Function is using a VPC Connector to route all traffic', region, func.name); + } else { + helpers.addResult(results, 2, + 'Cloud Function is using a VPC Connector for requests to private IPs only', region, func.name); + } + } else { + helpers.addResult(results, 2, + 'Cloud Function is not configured with Serverless VPC Access', region, func.name); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/cloudfunctionsv2/cloudFunctionV2VPCConnector.spec.js b/plugins/google/cloudfunctionsv2/cloudFunctionV2VPCConnector.spec.js new file mode 100644 index 0000000000..13ed92c6aa --- /dev/null +++ b/plugins/google/cloudfunctionsv2/cloudFunctionV2VPCConnector.spec.js @@ -0,0 +1,176 @@ +var expect = require('chai').expect; +var plugin = require('./cloudFunctionV2VPCConnector'); + + +const functions = [ + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "ingressSettings": "ALLOW_ALL" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-2", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "ingressSettings": "ALLOW_INTERNAL_AND_GCLB", + "vpcConnector": "projects/my-test-project/locations/us-central1/connectors/cloud-func-connector", + "vpcConnectorEgressSettings": "ALL_TRAFFIC" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-3", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "test@test-project.iam.gserviceaccount.com", + "vpcConnector": "projects/my-test-project/locations/us-central1/connectors/cloud-func-connector", + "vpcConnectorEgressSettings": "PRIVATE_RANGES_ONLY" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-4", + "environment": "GEN_1", + "state": "ACTIVE", + "runtime": "nodejs14", + "vpcConnector": "projects/my-test-project/locations/us-central1/connectors/cloud-func-connector" + } +]; + +const createCache = (list, err) => { + return { + functionsv2: { + list: { + 'us-central1': { + err: err, + data: list + } + } + } + } +}; + +describe('serverlessVPCAccess', function () { + describe('run', function () { + it('should give passing result if no Cloud Functions V2 found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Google Cloud functions found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Google Cloud functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Google Cloud functions'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Cloud Function is using a VPC Connector to route all traffic', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cloud Function is using a VPC Connector to route all traffic'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [functions[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Cloud Function is using a VPC Connector for requests to private IPs only', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cloud Function is using a VPC Connector for requests to private IPs only'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[2]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Cloud Function is not configured with Serverless VPC Access', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cloud Function is not configured with Serverless VPC Access'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should not check Gen 1 functions in v2 API response', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(0); + done(); + }; + + const cache = createCache( + [functions[3]], + null + ); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/cloudfunctionsv2/functionV2DefaultServiceAccount.js b/plugins/google/cloudfunctionsv2/functionV2DefaultServiceAccount.js new file mode 100644 index 0000000000..cceeadedb9 --- /dev/null +++ b/plugins/google/cloudfunctionsv2/functionV2DefaultServiceAccount.js @@ -0,0 +1,65 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Cloud Function V2 Default Service Account', + category: 'Cloud Functions', + domain: 'Serverless', + severity: 'Medium', + description: 'Ensure that Cloud Functions V2 are not using the default service account.', + more_info: 'Using the default service account for Cloud Functions V2 can lead to privilege escalation and overly permissive access. It is recommended to use a user-managed service account for each function in a project instead of the default service account. A managed service account allows more precise access control by granting only the necessary permissions through Identity and Access Management (IAM).', + link: 'https://cloud.google.com/functions/docs/securing/function-identity', + recommended_action: 'Ensure that no Cloud Functions V2 are using the default service account.', + apis: ['functionsv2:list'], + realtime_triggers: ['functions.CloudFunctionsService.UpdateFunction', 'functions.CloudFunctionsService.CreateFunction', 'functions.CloudFunctionsService.DeleteFunction'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + async.each(regions.functions, (region, rcb) => { + var functions = helpers.addSource(cache, source, + ['functionsv2', 'list', region]); + + if (!functions) return rcb(); + + if (functions.err || !functions.data) { + helpers.addResult(results, 3, + 'Unable to query for Google Cloud functions: ' + helpers.addError(functions), region, null, null, functions.err); + return rcb(); + } + + if (!functions.data.length) { + helpers.addResult(results, 0, 'No Google Cloud functions found', region); + return rcb(); + } + + functions.data.forEach(func => { + if (!func.name) return; + + if (!func.environment || func.environment !== 'GEN_2') return; + + let serviceAccountEmail = func.serviceConfig && func.serviceConfig.serviceAccountEmail + ? func.serviceConfig.serviceAccountEmail + : null; + + if (serviceAccountEmail && serviceAccountEmail.endsWith('@appspot.gserviceaccount.com')) { + helpers.addResult(results, 2, + 'Cloud Function is using default service account', region, func.name); + } else if (serviceAccountEmail) { + helpers.addResult(results, 0, + 'Cloud Function is not using default service account', region, func.name); + } else { + helpers.addResult(results, 2, + 'Cloud Function does not have a service account configured', region, func.name); + } + }); + + rcb(); + }, function() { + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/cloudfunctionsv2/functionV2DefaultServiceAccount.spec.js b/plugins/google/cloudfunctionsv2/functionV2DefaultServiceAccount.spec.js new file mode 100644 index 0000000000..0682572761 --- /dev/null +++ b/plugins/google/cloudfunctionsv2/functionV2DefaultServiceAccount.spec.js @@ -0,0 +1,173 @@ +var expect = require('chai').expect; +var plugin = require('./functionV2DefaultServiceAccount'); + + +const functions = [ + { + "name": "projects/my-test-project/locations/us-central1/functions/function-1", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "aqua@appspot.gserviceaccount.com", + "ingressSettings": "ALLOW_ALL" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-2", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "serviceAccountEmail": "custom-sa@my-test-project.iam.gserviceaccount.com", + "ingressSettings": "ALLOW_INTERNAL_AND_GCLB" + }, + "labels": { 'deployment-tool': 'console-cloud' } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-3", + "environment": "GEN_2", + "state": "ACTIVE", + "updateTime": "2021-09-24T06:18:15.265Z", + "buildConfig": { + "runtime": "nodejs20", + "entryPoint": "helloWorld" + }, + "serviceConfig": { + "ingressSettings": "ALLOW_INTERNAL_ONLY" + } + }, + { + "name": "projects/my-test-project/locations/us-central1/functions/function-4", + "environment": "GEN_1", + "state": "ACTIVE", + "runtime": "nodejs14", + "serviceAccountEmail": "aqua@appspot.gserviceaccount.com" + } +]; + +const createCache = (list, err) => { + return { + functionsv2: { + list: { + 'us-central1': { + err: err, + data: list + } + } + } + } +}; + +describe('functionDefaultServiceAccount', function () { + describe('run', function () { + it('should give passing result if no Cloud Functions V2 found', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('No Google Cloud functions found'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give unknown result if unable to query for Google Cloud functions', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(3); + expect(results[0].message).to.include('Unable to query for Google Cloud functions'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [], + {message: 'error'}, + ); + + plugin.run(cache, {}, callback); + }); + + it('should give passing result if Cloud Function is not using default service account', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(0); + expect(results[0].message).to.include('Cloud Function is not using default service account'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const cache = createCache( + [functions[1]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Cloud Function is using default service account', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cloud Function is using default service account'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[0]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should give failing result if Cloud Function does not have a service account configured', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Cloud Function does not have a service account configured'); + expect(results[0].region).to.equal('us-central1'); + done(); + }; + + const cache = createCache( + [functions[2]], + null + ); + + plugin.run(cache, {}, callback); + }); + + it('should not check Gen 1 functions in v2 API response', function (done) { + const callback = (err, results) => { + expect(results.length).to.equal(0); + done(); + }; + + const cache = createCache( + [functions[3]], + null + ); + + plugin.run(cache, {}, callback); + }); + + }) +}); + diff --git a/plugins/google/compute/computePrivilegeAnalysis.js b/plugins/google/compute/computePrivilegeAnalysis.js new file mode 100644 index 0000000000..49c0b3b1ba --- /dev/null +++ b/plugins/google/compute/computePrivilegeAnalysis.js @@ -0,0 +1,20 @@ +module.exports = { + title: 'Privilege Analysis', + category: 'Compute', + domain: 'Compute', + severity: 'Info', + description: 'Ensures that no compute instances in your cloud has excessive permissions.', + more_info: 'Compute instances having service account attached with excessive permissions can lead to security risks. Compute instances should have restrictive permissions assigned through service accounts for security best practices.', + link: 'https://cloud.google.com/compute/docs/access/iam', + recommended_action: 'Make sure that compute instances are using service account with only required permissions.', + apis: [''], + realtime_triggers: ['compute.instances.insert', 'compute.instances.delete'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + callback(null, results, source); + + } +}; diff --git a/plugins/google/compute/instanceLeastPrivilege.js b/plugins/google/compute/instanceLeastPrivilege.js index 7974b2a8dd..654b341864 100644 --- a/plugins/google/compute/instanceLeastPrivilege.js +++ b/plugins/google/compute/instanceLeastPrivilege.js @@ -1,4 +1,4 @@ -var async = require('async'); +var async = require('async'); var helpers = require('../../../helpers/google'); module.exports = { @@ -10,7 +10,7 @@ module.exports = { more_info: 'To support the principle of least privilege and prevent potential privilege escalation, it is recommended that instances are not assigned to the default service account, Compute Engine default service account with a scope allowing full access to all cloud APIs.', link: 'https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances', recommended_action: 'For all instances, if the default service account is used, ensure full access to all cloud APIs is not configured.', - apis: ['compute:list'], + apis: ['compute:list', 'projects:getIamPolicy'], compliance: { pci: 'PCI has explicit requirements around default accounts and ' + 'resources. PCI recommends removing all default accounts, ' + @@ -35,52 +35,104 @@ module.exports = { var project = projects.data[0].name; - async.each(regions.compute, (region, rcb) => { - var zones = regions.zones; - var noInstances = []; + var serviceAccountRoles = {}; - async.each(zones[region], function(zone, zcb) { - var instances = helpers.addSource(cache, source, - ['compute','list', zone]); + async.each(regions.projects, function(region, rcb) { + let iamPolicy = helpers.addSource(cache, source, + ['projects', 'getIamPolicy', region]); - if (!instances) return zcb(); + if (!iamPolicy) return rcb(); - if (instances.err || !instances.data) { - helpers.addResult(results, 3, 'Unable to query compute instances', region, null, null, instances.err); - return zcb(); - } + if (iamPolicy.err || !iamPolicy.data || !iamPolicy.data.length) { + helpers.addResult(results, 3, + 'Unable to query for IAM policies: ' + helpers.addError(iamPolicy), region); + return rcb(); + } - if (!instances.data.length) { - noInstances.push(zone); - return zcb(); - } + var iamPolicyData = iamPolicy.data[0]; + + if (iamPolicyData && iamPolicyData.bindings && iamPolicyData.bindings.length) { + iamPolicyData.bindings.forEach(roleBinding => { + if (!roleBinding.role || !roleBinding.members) return; + + var role = roleBinding.role; + + roleBinding.members.forEach(member => { + if (member.startsWith('serviceAccount:')) { + var serviceAccountEmail = member.split(':')[1]; + + if (!serviceAccountRoles[serviceAccountEmail]) { + serviceAccountRoles[serviceAccountEmail] = []; + } + serviceAccountRoles[serviceAccountEmail].push(role); + } + }); + }); + } + + rcb(); + }, function() { + async.each(regions.compute, (computeRegion, computeRcb) => { + var zones = regions.zones; + var noInstances = []; - instances.data.forEach(instance => { - let found = false; - if (instance.serviceAccounts && instance.serviceAccounts.length) { - found = instance.serviceAccounts.find(serviceAccount => serviceAccount.scopes && - serviceAccount.scopes.indexOf('https://www.googleapis.com/auth/cloud-platform') > -1); + async.each(zones[computeRegion], function(zone, zcb) { + var instances = helpers.addSource(cache, source, + ['compute', 'list', zone]); + + if (!instances) return zcb(); + + if (instances.err || !instances.data) { + helpers.addResult(results, 3, 'Unable to query compute instances', computeRegion, null, null, instances.err); + return zcb(); + } + + if (!instances.data.length) { + noInstances.push(zone); + return zcb(); } - let resource = helpers.createResourceName('instances', instance.name, project, 'zone', zone); + instances.data.forEach(instance => { + let resource = helpers.createResourceName('instances', instance.name, project, 'zone', zone); - if (found) { - helpers.addResult(results, 2, - 'Instance Service account has full access' , region, resource); - } else { - helpers.addResult(results, 0, - 'Instance Service account follows least privilege' , region, resource); + let instanceServiceAccountEmail = null; + let hasBroadRole = false; + + if (instance.serviceAccounts && instance.serviceAccounts.length) { + instance.serviceAccounts.forEach(serviceAccount => { + if (serviceAccount.email) { + instanceServiceAccountEmail = serviceAccount.email; + var roles = serviceAccountRoles[serviceAccount.email] || []; + var broadRoles = roles.filter(role => + role === 'roles/owner' || + role === 'roles/editor' || + role.endsWith('.admin') + ); + if (broadRoles.length > 0) { + hasBroadRole = true; + } + } + }); + } + + if (hasBroadRole && instanceServiceAccountEmail) { + helpers.addResult(results, 2, + 'Instance Service account has full access', computeRegion, resource); + } else { + helpers.addResult(results, 0, + 'Instance service account follows least privilege', computeRegion, resource); + } + }); + return zcb(); + }, function() { + if (noInstances.length) { + helpers.addResult(results, 0, `No instances found in following zones: ${noInstances.join(', ')}`, computeRegion); } + computeRcb(); }); - return zcb(); - }, function(){ - if (noInstances.length) { - helpers.addResult(results, 0, `No instances found in following zones: ${noInstances.join(', ')}`, region); - } - rcb(); + }, function() { + callback(null, results, source); }); - }, function() { - callback(null, results, source); }); } }; diff --git a/plugins/google/compute/instanceLeastPrivilege.spec.js b/plugins/google/compute/instanceLeastPrivilege.spec.js index 7d12b42182..a634286184 100644 --- a/plugins/google/compute/instanceLeastPrivilege.spec.js +++ b/plugins/google/compute/instanceLeastPrivilege.spec.js @@ -2,20 +2,28 @@ var assert = require('assert'); var expect = require('chai').expect; var plugin = require('./instanceLeastPrivilege'); -const createCache = (instanceData, error) => { +const createCache = (instanceData, error, iamPolicyData, defaultServiceAccount) => { return { - compute: { - list: { - 'us-central1-a': { - data: instanceData, - err: error - } + compute: { + list: { + 'us-central1-a': { + data: instanceData, + err: error } + } }, projects: { get: { 'global': { - data: 'test-proj' + data: [{ + name: 'test-proj', + defaultServiceAccount: defaultServiceAccount || '123456789-compute@developer.gserviceaccount.com' + }] + } + }, + getIamPolicy: { + 'global': { + data: iamPolicyData || [] } } } @@ -33,9 +41,16 @@ describe('instanceLeastPrivilege', function () { done() }; + const defaultSA = '123456789-compute@developer.gserviceaccount.com'; + const iamPolicy = [{ + bindings: [] + }]; + const cache = createCache( [], - ['error'] + ['error'], + iamPolicy, + defaultSA ); plugin.run(cache, {}, callback); @@ -50,15 +65,22 @@ describe('instanceLeastPrivilege', function () { done() }; + const defaultSA = '123456789-compute@developer.gserviceaccount.com'; + const iamPolicy = [{ + bindings: [] + }]; + const cache = createCache( [], - null + null, + iamPolicy, + defaultSA ); plugin.run(cache, {}, callback); }); - it('should fail with full access service account', function (done) { + it('should fail when default service account has broad IAM role (editor)', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(2); @@ -67,6 +89,18 @@ describe('instanceLeastPrivilege', function () { done() }; + const defaultSA = '123456789-compute@developer.gserviceaccount.com'; + const iamPolicy = [{ + bindings: [ + { + role: 'roles/editor', + members: [ + 'serviceAccount:' + defaultSA + ] + } + ] + }]; + const cache = createCache( [ { @@ -76,7 +110,7 @@ describe('instanceLeastPrivilege', function () { 'https://www.googleapis.com/compute/v1/projects/lofty-advantage-242315/zones/us-central1-a', serviceAccounts: [ { - email: '413092707322-compute@developer.gserviceaccount.com', + email: defaultSA, scopes: [ 'https://www.googleapis.com/auth/cloud-platform' ] @@ -84,21 +118,35 @@ describe('instanceLeastPrivilege', function () { ] } ], - null + null, + iamPolicy, + defaultSA ); plugin.run(cache, {}, callback); }); - it('should pass with no full access service account', function (done) { + it('should pass when default service account has restricted IAM roles', function (done) { const callback = (err, results) => { expect(results.length).to.be.above(0); expect(results[0].status).to.equal(0); - expect(results[0].message).to.include('Instance Service account follows least privilege'); + expect(results[0].message).to.include('follows least privilege'); expect(results[0].region).to.equal('us-central1'); done() }; + const defaultSA = '123456789-compute@developer.gserviceaccount.com'; + const iamPolicy = [{ + bindings: [ + { + role: 'roles/storage.objectViewer', + members: [ + 'serviceAccount:' + defaultSA + ] + } + ] + }]; + const cache = createCache( [ { @@ -108,19 +156,160 @@ describe('instanceLeastPrivilege', function () { 'https://www.googleapis.com/compute/v1/projects/lofty-advantage-242315/zones/us-central1-a', serviceAccounts: [ { - email: '413092707322-compute@developer.gserviceaccount.com', + email: defaultSA, scopes: [ - 'https://www.googleapis.com/auth/devstorage.read_only', - 'https://www.googleapis.com/auth/logging.write' + 'https://www.googleapis.com/auth/cloud-platform' ] } ] } + ], + null, + iamPolicy, + defaultSA + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail when custom service account has broad IAM role', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Instance Service account has full access'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const defaultSA = '123456789-compute@developer.gserviceaccount.com'; + const customSA = 'custom-sa@test-proj.iam.gserviceaccount.com'; + const iamPolicy = [{ + bindings: [ + { + role: 'roles/editor', + members: [ + 'serviceAccount:' + customSA + ] + } ] + }]; + + const cache = createCache( + [ + { + name: 'instance-1', + description: '', + zone: + 'https://www.googleapis.com/compute/v1/projects/lofty-advantage-242315/zones/us-central1-a', + serviceAccounts: [ + { + email: customSA, + scopes: [ + 'https://www.googleapis.com/auth/cloud-platform' + ] + } + ] + } + ], + null, + iamPolicy, + defaultSA + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail when default service account has owner role', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Instance Service account has full access'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const defaultSA = '123456789-compute@developer.gserviceaccount.com'; + const iamPolicy = [{ + bindings: [ + { + role: 'roles/owner', + members: [ + 'serviceAccount:' + defaultSA + ] + } + ] + }]; + + const cache = createCache( + [ + { + name: 'instance-1', + description: '', + zone: + 'https://www.googleapis.com/compute/v1/projects/lofty-advantage-242315/zones/us-central1-a', + serviceAccounts: [ + { + email: defaultSA, + scopes: [ + 'https://www.googleapis.com/auth/cloud-platform' + ] + } + ] + } + ], + null, + iamPolicy, + defaultSA + ); + + plugin.run(cache, {}, callback); + }); + + it('should fail when default service account has admin role', function (done) { + const callback = (err, results) => { + expect(results.length).to.be.above(0); + expect(results[0].status).to.equal(2); + expect(results[0].message).to.include('Instance Service account has full access'); + expect(results[0].region).to.equal('us-central1'); + done() + }; + + const defaultSA = '123456789-compute@developer.gserviceaccount.com'; + const iamPolicy = [{ + bindings: [ + { + role: 'roles/compute.admin', + members: [ + 'serviceAccount:' + defaultSA + ] + } + ] + }]; + + const cache = createCache( + [ + { + name: 'instance-1', + description: '', + zone: + 'https://www.googleapis.com/compute/v1/projects/lofty-advantage-242315/zones/us-central1-a', + serviceAccounts: [ + { + email: defaultSA, + scopes: [ + 'https://www.googleapis.com/auth/cloud-platform' + ] + } + ] + } + ], + null, + iamPolicy, + defaultSA ); plugin.run(cache, {}, callback); }) }) -}) \ No newline at end of file +}) diff --git a/plugins/google/compute/instanceNetworkExposure.js b/plugins/google/compute/instanceNetworkExposure.js index 2b896162b2..62b7336c9b 100644 --- a/plugins/google/compute/instanceNetworkExposure.js +++ b/plugins/google/compute/instanceNetworkExposure.js @@ -2,7 +2,7 @@ var async = require('async'); var helpers = require('../../../helpers/google'); module.exports = { - title: 'Network Exposure', + title: 'Internet Exposure', category: 'Compute', domain: 'Compute', severity: 'Info', @@ -10,8 +10,15 @@ module.exports = { more_info: 'Virtual machines exposed to the internet are at a higher risk of unauthorized access, data breaches, and cyberattacks. It’s crucial to limit exposure by securing access through proper configuration of network and firewall rules.', link: 'https://cloud.google.com/firewall/docs/firewalls', recommended_action: 'Secure VM instances by restricting access with properly configured security group and firewall rules.', - apis: ['compute:list', 'firewalls:list'], - realtime_triggers: ['compute.instances.insert', 'compute.instances.delete','compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], + apis: ['instanceGroups:aggregatedList', 'compute:list', 'firewalls:list', 'instanceGroups:listInstances', 'urlMaps:list', 'targetHttpProxies:list', 'targetHttpsProxies:list', + 'forwardingRules:list', 'backendServices:list' + ], + realtime_triggers: ['compute.instances.insert', 'compute.instances.delete', 'compute.instances.update', 'compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch', + 'compute.backendServices.insert', 'compute.backendServices.delete', 'compute.backendServices.patch', 'compute.instanceGroups.insert', 'compute.instanceGroups.delete', 'compute.instanceGroups.update', + 'compute.instanceGroups.addInstances', 'compute.instanceGroups.removeInstances', 'compute.urlMaps.insert', 'compute.urlMaps.delete', 'compute.urlMaps.update', 'compute.urlMaps.patch', + 'compute.targetHttpProxies.insert', 'compute.targetHttpProxies.delete', 'compute.targetHttpProxies.patch', 'compute.targetHttpsProxies.insert', 'compute.targetHttpsProxies.delete', 'compute.targetHttpsProxies.patch', + 'compute.forwardingRules.insert', 'compute.forwardingRules.delete', 'compute.forwardingRules.patch' + ], run: function(cache, settings, callback) { var results = []; @@ -71,6 +78,7 @@ module.exports = { let serviceAccount = instance.serviceAccounts && instance.serviceAccounts[0] && instance.serviceAccounts[0].email ? instance.serviceAccounts[0].email : ''; let firewallRules = firewalls.data.filter(rule => { + if (!rule.network) return false; let isNetworkMatch = networks.some(network => rule.network.endsWith(network)); let isTagMatch = rule.targetTags ? rule.targetTags.some(tag => tags.includes(tag)) : true; @@ -82,8 +90,32 @@ module.exports = { }); + networks = networks.map(network => network.split('/').pop()); - let internetExposed = helpers.checkNetworkExposure(cache, source, networks, firewallRules, region, results); + + // get all instance groups for instance + let instanceGroups = []; + + var instanceList = helpers.addSource(cache, source, + ['instanceGroups','listInstances', 'global']); + + if (instanceList && !instanceList.err && instanceList.data && instanceList.data.length) { + let groups = instanceList.data.filter(list => list.instance === instance.selfLink); + if (groups && groups.length) { + instanceGroups = groups.map(group => group.parent); + } + } + + + let forwardingRules = []; + if (instanceGroups && instanceGroups.length) { + instanceGroups.forEach(instanceGroup => { + let igForwardingRules = helpers.getForwardingRules(cache, source, region, instanceGroup); + forwardingRules = forwardingRules.concat(igForwardingRules); + }); + + } + let internetExposed = helpers.checkNetworkExposure(cache, source, networks, firewallRules, region, results, forwardingRules); let resource = helpers.createResourceName('instances', instance.name, project, 'zone', zone); @@ -106,3 +138,4 @@ module.exports = { }); } }; + diff --git a/plugins/google/kubernetes/clusterNetworkExposure.js b/plugins/google/kubernetes/clusterNetworkExposure.js new file mode 100644 index 0000000000..f7572be4fd --- /dev/null +++ b/plugins/google/kubernetes/clusterNetworkExposure.js @@ -0,0 +1,108 @@ +var async = require('async'); +var helpers = require('../../../helpers/google'); + +module.exports = { + title: 'Internet Exposure', + category: 'Kubernetes', + domain: 'Containers', + severity: 'Info', + description: 'Check if GKE clusters are exposed to the internet.', + more_info: 'GKE clusters exposed to the internet are at a higher risk of unauthorized access, data breaches, and cyberattacks. It’s crucial to limit exposure by securing the Kubernetes API, nodes, and services through proper configuration of network, firewall rules, and private clusters.', + link: 'https://cloud.google.com/kubernetes-engine/docs/how-to/private-clusters', + recommended_action: 'Secure GKE clusters by enabling private clusters, restricting access to the Kubernetes API, and ensuring nodes and services are protected through properly configured firewall rules and network policies.', + apis: ['kubernetes:list', 'firewalls:list'], + realtime_triggers: ['container.ClusterManager.CreateCluster', 'container.ClusterManager.DeleteCluster','container.ClusterManager.UpdateCluster', 'container.ClusterManager.CreateNodePool','container.ClusterManager.DeleteNodePool', + 'compute.firewalls.insert', 'compute.firewalls.delete', 'compute.firewalls.patch'], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + var regions = helpers.regions(); + + let projects = helpers.addSource(cache, source, + ['projects','get', 'global']); + + if (!projects || projects.err || !projects.data || !projects.data.length) { + helpers.addResult(results, 3, + 'Unable to query for projects: ' + helpers.addError(projects), 'global', null, null, (projects) ? projects.err : null); + return callback(null, results, source); + } + + var project = projects.data[0].name; + + let firewalls = helpers.addSource( + cache, source, ['firewalls', 'list', 'global']); + + if (!firewalls || firewalls.err || !firewalls.data) { + helpers.addResult(results, 3, 'Unable to query firewall rules', 'global', null, null, firewalls.err); + } + + if (!firewalls.data.length) { + helpers.addResult(results, 0, 'No firewall rules found', 'global'); + } + + async.each(regions.kubernetes, function(region, rcb){ + let clusters = helpers.addSource(cache, source, + ['kubernetes', 'list', region]); + + if (!clusters) return rcb(); + + if (clusters.err || !clusters.data) { + helpers.addResult(results, 3, 'Unable to query Kubernetes clusters', region, null, null, clusters.err); + return rcb(); + } + + if (!clusters.data.length) { + helpers.addResult(results, 0, 'No Kubernetes clusters found', region); + return rcb(); + } + + + clusters.data.forEach(cluster => { + + let location; + if (cluster.locations) { + location = cluster.locations.length === 1 ? cluster.locations[0] : cluster.locations[0].substring(0, cluster.locations[0].length - 2); + } else location = region; + + let resource = helpers.createResourceName('clusters', cluster.name, project, 'location', location); + let internetExposed = ''; + if (helpers.checkClusterExposure(cluster)) { + internetExposed = 'public endpoint access'; + } else { + let clusterNetwork = cluster.networkConfig && cluster.networkConfig.network ? cluster.networkConfig.network : cluster.network; + if (clusterNetwork && !clusterNetwork.includes('/')) clusterNetwork = `${clusterNetwork}`; + let firewallRules = firewalls.data.filter(rule => { + return rule.network && rule.network.endsWith(clusterNetwork); + }); + + + let isExposed = helpers.checkFirewallRules(firewallRules); + if (isExposed && isExposed.exposed && isExposed.networkName) { + internetExposed = isExposed.networkName; + } else { + // check node pools + let exposedNodePools = Array.isArray(cluster.nodePools) ? cluster.nodePools.filter(nodepool => nodepool.networkConfig && !nodepool.networkConfig.enablePrivateNodes).map(nodepool => nodepool.name) : [] ; + if (exposedNodePools.length) { + internetExposed = `node pools ${exposedNodePools.join(',')}`; + } + } + + } + if (internetExposed && internetExposed.length) { + helpers.addResult(results, 2, `Cluster is exposed to the internet through ${internetExposed}`, region, resource); + } else { + helpers.addResult(results, 0, 'Cluster is not exposed to the internet', region, resource); + } + + + }); + + rcb(); + }, function(){ + // Global checking goes here + callback(null, results, source); + }); + } +}; + diff --git a/plugins/google/kubernetes/kubernetesPrivilegeAnalysis.js b/plugins/google/kubernetes/kubernetesPrivilegeAnalysis.js new file mode 100644 index 0000000000..8d61c30a34 --- /dev/null +++ b/plugins/google/kubernetes/kubernetesPrivilegeAnalysis.js @@ -0,0 +1,25 @@ +module.exports = { + title: 'Privilege Analysis', + category: 'Kubernetes', + domain: 'Containers', + severity: 'Info', + description: 'Ensures that Kubernetes workloads and service accounts are not granted excessive permissions.', + more_info: 'Kubernetes workloads often use service accounts to interact with the Kubernetes API and other GCP resources. Over-privileged service accounts can lead to privilege escalation or lateral movement within the cluster or the cloud environment. Following the principle of least privilege helps minimize potential attack surfaces.', + link: 'https://cloud.google.com/kubernetes-engine/docs/how-to/iam', + recommended_action: 'Review and minimize IAM permissions granted to Kubernetes service accounts and workload identities. Use role-based access control (RBAC) and GCP IAM best practices to ensure only required access is permitted.', + apis: [''], + realtime_triggers: [ + 'container.projects.updateCluster', + 'container.projects.createCluster', + 'container.projects.deleteCluster', + 'iam.serviceAccounts.setIamPolicy', + 'iam.serviceAccounts.getIamPolicy' + ], + + run: function(cache, settings, callback) { + var results = []; + var source = {}; + + callback(null, results, source); + } +};